5.14 시작전
This commit is contained in:
@@ -326,15 +326,9 @@
|
||||
Text="초" />
|
||||
</StackPanel>
|
||||
|
||||
<ToggleSwitch
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Header="반복"
|
||||
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
|
||||
|
||||
<ComboBox
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Grid.Column="0"
|
||||
Width="150"
|
||||
Header="빈 스케줄"
|
||||
DisplayMemberPath="Label"
|
||||
@@ -343,7 +337,7 @@
|
||||
|
||||
<Button
|
||||
Grid.Row="1"
|
||||
Grid.Column="2"
|
||||
Grid.Column="1"
|
||||
Width="22"
|
||||
Height="22"
|
||||
MinWidth="22"
|
||||
@@ -364,18 +358,34 @@
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Spacing="10">
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.DirectStartCommand}"
|
||||
Content="시작"
|
||||
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.DirectStopCommand}"
|
||||
Content="정지"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
</StackPanel>
|
||||
<Border
|
||||
Padding="12"
|
||||
Background="#101C2E"
|
||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock
|
||||
Style="{StaticResource ConsoleLabelTextStyle}"
|
||||
Text="선택컷 송출 제어" />
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Spacing="10">
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.DirectPrepareCommand}"
|
||||
Content="준비"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.DirectStartCommand}"
|
||||
Content="시작"
|
||||
Style="{StaticResource ConsolePrimaryButtonStyle}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.DirectStopCommand}"
|
||||
Content="정지"
|
||||
Style="{StaticResource ConsoleGhostButtonStyle}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
Padding="12"
|
||||
@@ -770,6 +780,13 @@
|
||||
HorizontalAlignment="Right"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<ToggleSwitch
|
||||
Header="반복"
|
||||
IsOn="{x:Bind ViewModel.LoopEnabled, Mode=TwoWay}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.SchedulePrepareCommand}"
|
||||
Content="준비"
|
||||
Style="{StaticResource PanelCommandButtonStyle}" />
|
||||
<Button
|
||||
Command="{x:Bind ViewModel.StartCommand}"
|
||||
Content="스케줄 시작"
|
||||
|
||||
@@ -230,7 +230,9 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
[JsonIgnore]
|
||||
public string PreviewStatusLabel => HasRenderedPreview
|
||||
? _renderedPreviewStatusLabel
|
||||
: "실데이터 프리뷰 준비 중";
|
||||
: string.IsNullOrWhiteSpace(_renderedPreviewStatusLabel)
|
||||
? "실데이터 프리뷰 준비 중"
|
||||
: _renderedPreviewStatusLabel;
|
||||
|
||||
[JsonIgnore]
|
||||
public ImageSource? InternalNextPreviewSource => _internalNextPreviewSource;
|
||||
@@ -281,6 +283,12 @@ public sealed class ChannelScheduleItem : ObservableObject
|
||||
OnPreviewChanged();
|
||||
}
|
||||
|
||||
public void UpdateRenderedPreviewStatus(string statusLabel)
|
||||
{
|
||||
_renderedPreviewStatusLabel = statusLabel;
|
||||
OnPreviewChanged();
|
||||
}
|
||||
|
||||
public void UpdateInternalNextPreview(string previewPath, string displayName, string statusLabel)
|
||||
{
|
||||
_internalNextPreviewPath = previewPath;
|
||||
|
||||
@@ -74,4 +74,5 @@ public sealed record TurnoutBoardSlotEntry(
|
||||
string Label,
|
||||
double TurnoutRate,
|
||||
bool IsNational = false,
|
||||
string RegionLabel = "");
|
||||
string RegionLabel = "",
|
||||
bool HasTurnoutData = true);
|
||||
|
||||
@@ -49,9 +49,9 @@
|
||||
<NavigationViewItem Content="하단" Tag="bottom" Visibility="{x:Bind ViewModel.BottomMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.BottomChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Download" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="비디오월" Tag="videowall" Visibility="{x:Bind ViewModel.VideoWallMenuVisibility, Mode=OneWay}"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.VideoWallChannel.PlaybackIconBrush, Mode=OneWay}" Symbol="Video" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="사전데이터" Tag="pre-election-data"><NavigationViewItem.Icon><SymbolIcon Symbol="Library" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="투표데이터" Tag="turnout-data"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="개표데이터" Tag="counting-data"><NavigationViewItem.Icon><SymbolIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Symbol="Edit" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="공약데이터" Tag="career-promises"><NavigationViewItem.Icon><SymbolIcon Symbol="Contact" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="투표데이터" Tag="turnout-data"><NavigationViewItem.Icon><PathIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Data="M4,2 H13 V3 H4 Z M4,2 H5 V22 H4 Z M4,21 H18 V22 H4 Z M17,7 H18 V22 H17 Z M13,2 L18,7 H13 Z M6,8 H8 V10 H6 Z M10,8 H15 V9 H10 Z M6,12 H8 V14 H6 Z M10,12 H15 V13 H10 Z M6,16 H8 V18 H6 Z M10,16 H14 V17 H10 Z" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="개표데이터" Tag="counting-data"><NavigationViewItem.Icon><PathIcon Foreground="{x:Bind ViewModel.DataNavigationIconBrush, Mode=OneWay}" Data="M10,2 H15 V8 H10 Z M11,4 H14 V5 H11 Z M4,9 H20 V11 H4 Z M6,7 L10,5 L11,6 L7,8 Z M18,7 L14,5 L13,6 L17,8 Z M5,12 H19 L17,21 H7 Z M7,14 L8,19 H16 L17,14 Z" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="컷리스트" Tag="cut-list"><NavigationViewItem.Icon><SymbolIcon Symbol="Bullets" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="설정" Tag="settings"><NavigationViewItem.Icon><SymbolIcon Symbol="Setting" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
<NavigationViewItem Content="로그" Tag="log"><NavigationViewItem.Icon><SymbolIcon Symbol="Document" /></NavigationViewItem.Icon></NavigationViewItem>
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Tornado3_2026Election.Services;
|
||||
public sealed class ChannelScheduleEngine
|
||||
{
|
||||
private const int PreviewFrame = -1;
|
||||
private static readonly TimeSpan MinimumNextPreviewWindow = TimeSpan.FromSeconds(2.5);
|
||||
private readonly ITornado3Adapter _adapter;
|
||||
private readonly IDataRefreshGate _dataRefreshGate;
|
||||
private readonly Func<BroadcastStationProfile> _stationProvider;
|
||||
@@ -26,6 +27,7 @@ public sealed class ChannelScheduleEngine
|
||||
private Guid? _preferredNextItemId;
|
||||
private Guid? _skipCurrentItemId;
|
||||
private ChannelScheduleItem? _directPlaybackItem;
|
||||
private PreparedCutFrame? _preparedCutFrame;
|
||||
|
||||
public ChannelScheduleEngine(
|
||||
BroadcastChannel channel,
|
||||
@@ -66,6 +68,11 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
public event EventHandler? QueueChanged;
|
||||
|
||||
public bool IsPreparedItem(ChannelScheduleItem item)
|
||||
{
|
||||
return _preparedCutFrame?.Item.Id == item.Id;
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (IsRunning)
|
||||
@@ -74,6 +81,11 @@ public sealed class ChannelScheduleEngine
|
||||
return;
|
||||
}
|
||||
|
||||
if (_preparedCutFrame is { Item: var preparedItem } && !Queue.Contains(preparedItem))
|
||||
{
|
||||
ClearPreparedFrame(resetState: true);
|
||||
}
|
||||
|
||||
_playbackCts = new CancellationTokenSource();
|
||||
IsRunning = true;
|
||||
RefreshQueueMarkers();
|
||||
@@ -85,6 +97,21 @@ public sealed class ChannelScheduleEngine
|
||||
{
|
||||
if (!IsRunning)
|
||||
{
|
||||
if (_preparedCutFrame is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (takeOutputOff)
|
||||
{
|
||||
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ClearPreparedFrame(resetState: true);
|
||||
_preferredNextItemId = null;
|
||||
_skipCurrentItemId = null;
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,11 +132,97 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
_preferredNextItemId = null;
|
||||
_skipCurrentItemId = null;
|
||||
ClearPreparedFrame(resetState: false);
|
||||
IsRunning = false;
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async Task PrepareNextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ClearPreparedFrame(resetState: true);
|
||||
RefreshQueueMarkers();
|
||||
|
||||
var next = GetNextPlayableItem();
|
||||
if (next is null)
|
||||
{
|
||||
_logService.Warning($"[{Channel}] 준비할 스케줄 컷이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
var template = _templateResolver(next.FormatId);
|
||||
if (template is null)
|
||||
{
|
||||
next.State = ScheduleQueueItemState.Error;
|
||||
next.LastError = "포맷을 찾을 수 없습니다.";
|
||||
_logService.Error($"[{Channel}] Missing template: {next.FormatId}");
|
||||
RefreshQueueMarkers();
|
||||
return;
|
||||
}
|
||||
|
||||
await PrepareFirstCutAsync(next, template, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_executionLock.Release();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PrepareDirectAsync(
|
||||
ChannelScheduleItem item,
|
||||
FormatTemplateDefinition template,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ClearPreparedFrame(resetState: true);
|
||||
_directPlaybackItem = item;
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
await PrepareFirstCutAsync(item, template, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (_directPlaybackItem == item)
|
||||
{
|
||||
_directPlaybackItem = null;
|
||||
}
|
||||
|
||||
item.State = ScheduleQueueItemState.Queued;
|
||||
item.CurrentRegionLabel = string.Empty;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_executionLock.Release();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearDirectPlayback()
|
||||
{
|
||||
ClearPreparedFrame(resetState: true);
|
||||
if (_directPlaybackItem is not null)
|
||||
{
|
||||
_directPlaybackItem.State = ScheduleQueueItemState.Queued;
|
||||
_directPlaybackItem.CurrentRegionLabel = string.Empty;
|
||||
_directPlaybackItem.ClearRenderedPreview();
|
||||
_directPlaybackItem.ClearInternalNextPreview();
|
||||
_directPlaybackItem = null;
|
||||
}
|
||||
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async Task PlayDirectAsync(
|
||||
ChannelScheduleItem item,
|
||||
FormatTemplateDefinition template,
|
||||
@@ -139,6 +252,7 @@ public sealed class ChannelScheduleEngine
|
||||
public void Reset()
|
||||
{
|
||||
_preferredNextItemId = null;
|
||||
ClearPreparedFrame(resetState: false);
|
||||
foreach (var item in Queue)
|
||||
{
|
||||
item.State = ScheduleQueueItemState.Queued;
|
||||
@@ -311,12 +425,94 @@ public sealed class ChannelScheduleEngine
|
||||
IsRunning = false;
|
||||
RefreshQueueMarkers();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsRunning = false;
|
||||
var sendingItem = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Sending);
|
||||
if (sendingItem is not null)
|
||||
{
|
||||
sendingItem.State = ScheduleQueueItemState.Error;
|
||||
sendingItem.LastError = ex.Message;
|
||||
sendingItem.CurrentRegionLabel = string.Empty;
|
||||
sendingItem.ClearInternalNextPreview();
|
||||
ClearSkipCurrentItem(sendingItem);
|
||||
}
|
||||
|
||||
_logService.Error($"[{Channel}] Schedule playback stopped: {ex.Message}");
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_executionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrepareFirstCutAsync(
|
||||
ChannelScheduleItem queueItem,
|
||||
FormatTemplateDefinition template,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
queueItem.State = ScheduleQueueItemState.Sending;
|
||||
queueItem.LastError = string.Empty;
|
||||
queueItem.ClearInternalNextPreview();
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
var station = _stationProvider();
|
||||
var imageRootPath = _imageRootProvider();
|
||||
CutPreviewFrame? previewFrame;
|
||||
try
|
||||
{
|
||||
previewFrame = await TryBuildPreviewFrameAsync(queueItem, template, station, imageRootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
queueItem.State = ScheduleQueueItemState.Error;
|
||||
queueItem.LastError = ex.Message;
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
RefreshQueueMarkers();
|
||||
_logService.Warning($"[{Channel}] 준비 컷 데이터 구성 실패: {queueItem.DisplayName} / {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewFrame is null)
|
||||
{
|
||||
queueItem.State = ScheduleQueueItemState.Error;
|
||||
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
|
||||
queueItem.CurrentRegionLabel = string.Empty;
|
||||
RefreshQueueMarkers();
|
||||
_logService.Warning($"[{Channel}] 준비할 수 있는 컷 데이터가 없습니다: {queueItem.DisplayName}");
|
||||
return;
|
||||
}
|
||||
|
||||
queueItem.CurrentRegionLabel = previewFrame.RegionLabel;
|
||||
|
||||
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _adapter
|
||||
.ApplyCutAsync(Channel, template, previewFrame.Cut, previewFrame.Snapshot, station, imageRootPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_preparedCutFrame = new PreparedCutFrame(
|
||||
queueItem,
|
||||
template,
|
||||
previewFrame.Cut,
|
||||
previewFrame.Snapshot,
|
||||
station,
|
||||
imageRootPath,
|
||||
previewFrame.RegionLabel);
|
||||
|
||||
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
RefreshQueueMarkers();
|
||||
}
|
||||
|
||||
private async Task PlayItemAsync(ChannelScheduleItem queueItem, FormatTemplateDefinition template, CancellationToken cancellationToken)
|
||||
{
|
||||
var station = _stationProvider();
|
||||
@@ -521,9 +717,22 @@ public sealed class ChannelScheduleEngine
|
||||
RefreshQueueMarkers();
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||
var preparedFrame = TryConsumePreparedFrame(queueItem, template, cut);
|
||||
var playbackCut = preparedFrame?.Cut ?? cut;
|
||||
var playbackSnapshot = preparedFrame?.Snapshot ?? snapshot;
|
||||
var playbackStation = preparedFrame?.Station ?? station;
|
||||
var playbackImageRootPath = preparedFrame?.ImageRootPath ?? imageRootPath;
|
||||
if (preparedFrame is not null)
|
||||
{
|
||||
queueItem.CurrentRegionLabel = preparedFrame.RegionLabel;
|
||||
}
|
||||
else
|
||||
{
|
||||
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
|
||||
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var onAirAt = DateTimeOffset.Now;
|
||||
@@ -539,36 +748,39 @@ public sealed class ChannelScheduleEngine
|
||||
signal.TrySetResult(true);
|
||||
}
|
||||
|
||||
await CaptureCurrentPreviewAsync(
|
||||
queueItem,
|
||||
template,
|
||||
cut,
|
||||
snapshot,
|
||||
station,
|
||||
imageRootPath,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
||||
var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
|
||||
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
if (!ShouldSkipCurrentItem(queueItem))
|
||||
{
|
||||
CutPreviewFrame? nextInternalPreviewFrame = null;
|
||||
if (nextInternalPreviewFrameFactory is not null)
|
||||
var remainingForPreview = playbackDuration - (DateTimeOffset.Now - onAirAt);
|
||||
if (remainingForPreview > MinimumNextPreviewWindow)
|
||||
{
|
||||
try
|
||||
CutPreviewFrame? nextInternalPreviewFrame = null;
|
||||
if (nextInternalPreviewFrameFactory is not null)
|
||||
{
|
||||
nextInternalPreviewFrame = await nextInternalPreviewFrameFactory(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
nextInternalPreviewFrame = await nextInternalPreviewFrameFactory(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning($"[{Channel}] 다음 지역 프리뷰 데이터 준비 실패: {queueItem.DisplayName} / {ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logService.Warning($"[{Channel}] 다음 지역 프리뷰 데이터 준비 실패: {queueItem.DisplayName} / {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await CaptureNextPreviewAsync(queueItem, template, nextInternalPreviewFrame, station, imageRootPath, cancellationToken).ConfigureAwait(false);
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
await CaptureNextPreviewAsync(queueItem, template, nextInternalPreviewFrame, station, imageRootPath, cancellationToken).ConfigureAwait(false);
|
||||
QueueChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
MarkQueueNextPreviewStatus(queueItem, $"빠른 송출 중 프리뷰 생략 {DateTimeOffset.Now:HH:mm:ss}");
|
||||
}
|
||||
}
|
||||
|
||||
if (ShouldSkipCurrentItem(queueItem))
|
||||
@@ -576,8 +788,7 @@ public sealed class ChannelScheduleEngine
|
||||
return;
|
||||
}
|
||||
|
||||
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
|
||||
var remainingDuration = TimeSpan.FromSeconds(durationSeconds) - (DateTimeOffset.Now - onAirAt);
|
||||
var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt);
|
||||
if (remainingDuration <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
@@ -590,10 +801,6 @@ public sealed class ChannelScheduleEngine
|
||||
private async Task CaptureCurrentPreviewAsync(
|
||||
ChannelScheduleItem queueItem,
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut,
|
||||
ElectionDataSnapshot snapshot,
|
||||
BroadcastStationProfile station,
|
||||
string imageRootPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_adapter.IsLiveCg)
|
||||
@@ -603,13 +810,8 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider());
|
||||
var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, queueItem.Id, "current");
|
||||
var captured = await _adapter.TryCaptureCutPreviewAsync(
|
||||
var captured = await _adapter.TryCapturePendingCutPreviewAsync(
|
||||
Channel,
|
||||
template,
|
||||
cut,
|
||||
snapshot,
|
||||
station,
|
||||
imageRootPath,
|
||||
previewPath,
|
||||
size.Width,
|
||||
size.Height,
|
||||
@@ -618,6 +820,8 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
if (!captured)
|
||||
{
|
||||
await UiDispatcher.EnqueueAsync(() =>
|
||||
queueItem.UpdateRenderedPreviewStatus($"현재 화면 캡처 지연 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -701,6 +905,8 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
if (!captured)
|
||||
{
|
||||
await UiDispatcher.EnqueueAsync(() =>
|
||||
nextItem.UpdateRenderedPreviewStatus($"다음 프리뷰 캡처 지연 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -708,6 +914,17 @@ public sealed class ChannelScheduleEngine
|
||||
nextItem.UpdateRenderedPreview(previewPath, $"다음 변수 적용 캡처 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void MarkQueueNextPreviewStatus(ChannelScheduleItem activeItem, string statusLabel)
|
||||
{
|
||||
var nextItem = GetPreviewNextItem(activeItem);
|
||||
if (nextItem is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UiDispatcher.Enqueue(() => nextItem.UpdateRenderedPreviewStatus(statusLabel));
|
||||
}
|
||||
|
||||
private async Task CaptureInternalNextPreviewAsync(
|
||||
ChannelScheduleItem activeItem,
|
||||
FormatTemplateDefinition template,
|
||||
@@ -904,6 +1121,59 @@ public sealed class ChannelScheduleEngine
|
||||
return _skipCurrentItemId == queueItem.Id;
|
||||
}
|
||||
|
||||
private PreparedCutFrame? TryConsumePreparedFrame(
|
||||
ChannelScheduleItem queueItem,
|
||||
FormatTemplateDefinition template,
|
||||
FormatCutDefinition cut)
|
||||
{
|
||||
if (_preparedCutFrame is not { } preparedFrame)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preparedFrame.Item.Id != queueItem.Id ||
|
||||
!string.Equals(preparedFrame.Template.Id, template.Id, StringComparison.Ordinal) ||
|
||||
!CutsMatch(preparedFrame.Cut, cut))
|
||||
{
|
||||
if (preparedFrame.Item.Id == queueItem.Id)
|
||||
{
|
||||
ClearPreparedFrame(resetState: false);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_preparedCutFrame = null;
|
||||
return preparedFrame;
|
||||
}
|
||||
|
||||
private static bool CutsMatch(FormatCutDefinition left, FormatCutDefinition right)
|
||||
{
|
||||
return string.Equals(left.Name, right.Name, StringComparison.Ordinal) &&
|
||||
left.CandidateStartIndex == right.CandidateStartIndex &&
|
||||
left.UseEndScene == right.UseEndScene &&
|
||||
string.Equals(left.SceneIdOverride, right.SceneIdOverride, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void ClearPreparedFrame(bool resetState)
|
||||
{
|
||||
if (_preparedCutFrame is not { } preparedFrame)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_preparedCutFrame = null;
|
||||
if (!resetState || preparedFrame.Item.State != ScheduleQueueItemState.Sending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
preparedFrame.Item.State = ScheduleQueueItemState.Queued;
|
||||
preparedFrame.Item.CurrentRegionLabel = string.Empty;
|
||||
preparedFrame.Item.ClearRenderedPreview();
|
||||
preparedFrame.Item.ClearInternalNextPreview();
|
||||
}
|
||||
|
||||
private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
|
||||
{
|
||||
if (_skipCurrentItemId == queueItem.Id)
|
||||
@@ -944,8 +1214,7 @@ public sealed class ChannelScheduleEngine
|
||||
return IsBottomTurnoutBoardTemplate(template);
|
||||
}
|
||||
|
||||
return (template.RecommendedChannel == BroadcastChannel.Normal &&
|
||||
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal)) ||
|
||||
return IsNormalPreElectionTurnoutDistrictBoardTemplate(template) ||
|
||||
IsTopTurnoutDistrictBoardTemplate(template);
|
||||
}
|
||||
|
||||
@@ -976,6 +1245,14 @@ public sealed class ChannelScheduleEngine
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
|
||||
{
|
||||
return regionTargets
|
||||
.GroupBy(ResolveCouncilSeatTableRegionKey, StringComparer.OrdinalIgnoreCase)
|
||||
.SelectMany(group => ChunkRegionTargets(group.ToArray(), 7))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (!IsBasicCouncilWinnerTemplate(template) &&
|
||||
!ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
|
||||
{
|
||||
@@ -1048,6 +1325,12 @@ public sealed class ChannelScheduleEngine
|
||||
string.Equals(template.Name, "투표율_선거구별", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsNormalPreElectionTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
|
||||
{
|
||||
return template.RecommendedChannel == BroadcastChannel.Normal &&
|
||||
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
|
||||
{
|
||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||
@@ -1327,6 +1610,11 @@ public sealed class ChannelScheduleEngine
|
||||
|
||||
private ChannelScheduleItem? GetNextPlayableItem()
|
||||
{
|
||||
if (_preparedCutFrame is { Item: var preparedItem } && Queue.Contains(preparedItem))
|
||||
{
|
||||
return preparedItem;
|
||||
}
|
||||
|
||||
return Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Next)
|
||||
?? Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Queued);
|
||||
}
|
||||
@@ -1361,5 +1649,14 @@ public sealed class ChannelScheduleEngine
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PreparedCutFrame(
|
||||
ChannelScheduleItem Item,
|
||||
FormatTemplateDefinition Template,
|
||||
FormatCutDefinition Cut,
|
||||
ElectionDataSnapshot Snapshot,
|
||||
BroadcastStationProfile Station,
|
||||
string ImageRootPath,
|
||||
string RegionLabel);
|
||||
|
||||
private sealed record CutPreviewFrame(FormatCutDefinition Cut, ElectionDataSnapshot Snapshot, string RegionLabel);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public static class CutCategoryResolver
|
||||
{
|
||||
private static readonly IReadOnlyList<CutCategory> OrderedCategories =
|
||||
[
|
||||
CutCategory.Title,
|
||||
CutCategory.MetropolitanHead,
|
||||
CutCategory.LocalHead,
|
||||
CutCategory.Superintendent,
|
||||
@@ -25,8 +26,7 @@ public static class CutCategoryResolver
|
||||
CutCategory.BottomElectionDayTurnout,
|
||||
CutCategory.PreElection,
|
||||
CutCategory.Historical,
|
||||
CutCategory.Turnout,
|
||||
CutCategory.Title
|
||||
CutCategory.Turnout
|
||||
];
|
||||
|
||||
public static IReadOnlyList<CutCategory> GetOrderedCategories() => OrderedCategories;
|
||||
@@ -37,7 +37,7 @@ public static class CutCategoryResolver
|
||||
|
||||
return category switch
|
||||
{
|
||||
CutCategory.MetropolitanHead => Contains(formatName, "광역단체장"),
|
||||
CutCategory.MetropolitanHead => IsMetropolitanHeadFormat(formatName),
|
||||
CutCategory.LocalHead => Contains(formatName, "기초단체장"),
|
||||
CutCategory.Superintendent => Contains(formatName, "교육감"),
|
||||
CutCategory.MetropolitanCouncil => Contains(formatName, "광역의원"),
|
||||
@@ -94,6 +94,12 @@ public static class CutCategoryResolver
|
||||
return value.Contains(token, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsMetropolitanHeadFormat(string formatName)
|
||||
{
|
||||
return Contains(formatName, "광역단체장") ||
|
||||
string.Equals(formatName, "사전_역대당선자", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsBottomCountingTemplate(FormatTemplateDefinition template, string prefix)
|
||||
{
|
||||
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
@@ -70,18 +71,15 @@ public static class CutThumbnailAssetCatalog
|
||||
|
||||
public static bool HasThumbnail(string templateId)
|
||||
{
|
||||
if (HasThumbnailPath(templateId))
|
||||
foreach (var candidateTemplateId in EnumerateThumbnailTemplateIds(templateId))
|
||||
{
|
||||
return true;
|
||||
if (HasThumbnailPath(candidateTemplateId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
|
||||
if (string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return HasThumbnailPath(fallbackTemplateId);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasThumbnailPath(string templateId)
|
||||
@@ -115,37 +113,64 @@ public static class CutThumbnailAssetCatalog
|
||||
|
||||
public static string ResolvePreferredDisplayPath(string templateId)
|
||||
{
|
||||
var projectPath = TryGetProjectAssetPath(templateId);
|
||||
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
|
||||
foreach (var candidateTemplateId in EnumerateThumbnailTemplateIds(templateId))
|
||||
{
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
var bundledPath = GetBundledAssetPath(templateId);
|
||||
if (File.Exists(bundledPath))
|
||||
{
|
||||
return bundledPath;
|
||||
}
|
||||
|
||||
var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
|
||||
if (!string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
|
||||
{
|
||||
var fallbackProjectPath = TryGetProjectAssetPath(fallbackTemplateId);
|
||||
if (!string.IsNullOrWhiteSpace(fallbackProjectPath) && File.Exists(fallbackProjectPath))
|
||||
var projectPath = TryGetProjectAssetPath(candidateTemplateId);
|
||||
if (!string.IsNullOrWhiteSpace(projectPath) && File.Exists(projectPath))
|
||||
{
|
||||
return fallbackProjectPath;
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
var fallbackBundledPath = GetBundledAssetPath(fallbackTemplateId);
|
||||
if (File.Exists(fallbackBundledPath))
|
||||
var bundledPath = GetBundledAssetPath(candidateTemplateId);
|
||||
if (File.Exists(bundledPath))
|
||||
{
|
||||
return fallbackBundledPath;
|
||||
return bundledPath;
|
||||
}
|
||||
}
|
||||
|
||||
return Path.Combine(AppContext.BaseDirectory, FallbackAssetPath);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateThumbnailTemplateIds(string templateId)
|
||||
{
|
||||
var preferredTemplateId = ResolvePreferredThumbnailTemplateId(templateId);
|
||||
if (!string.Equals(preferredTemplateId, templateId, StringComparison.Ordinal))
|
||||
{
|
||||
yield return preferredTemplateId;
|
||||
yield return templateId;
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return templateId;
|
||||
|
||||
var fallbackTemplateId = ResolveThumbnailTemplateId(templateId);
|
||||
if (!string.Equals(templateId, fallbackTemplateId, StringComparison.Ordinal))
|
||||
{
|
||||
yield return fallbackTemplateId;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePreferredThumbnailTemplateId(string templateId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(templateId))
|
||||
{
|
||||
return templateId;
|
||||
}
|
||||
|
||||
var normalizedId = templateId.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);
|
||||
var folder = Path.GetDirectoryName(normalizedId);
|
||||
var fileName = Path.GetFileName(normalizedId);
|
||||
|
||||
if (folder is not null &&
|
||||
string.Equals(folder, "Elect2026_Normal_민방", StringComparison.Ordinal) &&
|
||||
string.Equals(fileName, "투표율", StringComparison.Ordinal))
|
||||
{
|
||||
return Path.Combine(folder, "투표율_사진");
|
||||
}
|
||||
|
||||
return templateId;
|
||||
}
|
||||
|
||||
private static string ResolveThumbnailTemplateId(string templateId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(templateId))
|
||||
|
||||
@@ -86,6 +86,12 @@ public sealed class FormatCatalogService
|
||||
"1-3위_ani_기초단체장",
|
||||
"1-3위_기초단체장_5760",
|
||||
"1-3위_보궐선거",
|
||||
"2880_광역의원표",
|
||||
"2880_기초의원표",
|
||||
"810_광역의원표",
|
||||
"810_기초의원표",
|
||||
"8316_광역의원표",
|
||||
"8316_기초의원표",
|
||||
"경력_광역단체장_in",
|
||||
"경력_기초단체장_in",
|
||||
"광역의원표",
|
||||
@@ -129,10 +135,8 @@ public sealed class FormatCatalogService
|
||||
"접전_기초단체장",
|
||||
"초접전_광역단체장",
|
||||
"초접전_기초단체장",
|
||||
"투표율_사진",
|
||||
"투표율",
|
||||
"투표율_선거구별 사전",
|
||||
"투표율_시도별",
|
||||
"투표율_영상",
|
||||
"판세_광역단체장",
|
||||
"판세_기초단체장",
|
||||
"판세_기초단체장_5760"));
|
||||
@@ -208,6 +212,12 @@ public sealed class FormatCatalogService
|
||||
|
||||
private static string? ResolveSceneIdOverride(string relativeFolder, string baseName)
|
||||
{
|
||||
if (string.Equals(relativeFolder, "Elect2026_Normal_민방", StringComparison.Ordinal) &&
|
||||
string.Equals(baseName, "투표율", StringComparison.Ordinal))
|
||||
{
|
||||
return Path.Combine(relativeFolder, "투표율_사진");
|
||||
}
|
||||
|
||||
return baseName switch
|
||||
{
|
||||
"사전투표율_시도" or "사전투표율_시군구" => Path.Combine(relativeFolder, "사전투표율"),
|
||||
@@ -288,9 +298,12 @@ public sealed class FormatCatalogService
|
||||
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_L_1")] = Path.Combine("Elect2026_Normal_민방", "사전_역대투표율_5760"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_선거구별 사전"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율_시도별"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_사진")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_시도별_L_loop")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "투표율_영상")] = Path.Combine("Elect2026_Normal_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_7680")] = Path.Combine("Elect2026_Normal_민방", "판세_기초단체장_5760"),
|
||||
[Path.Combine("Elect2026_Top_민방", "투표율_loop")] = Path.Combine("Elect2026_Top_민방", "투표율"),
|
||||
[Path.Combine("Elect2026_Top_민방", "투표율_선거구별_loop")] = Path.Combine("Elect2026_Top_민방", "투표율_선거구별")
|
||||
@@ -299,7 +312,8 @@ public sealed class FormatCatalogService
|
||||
|
||||
private static bool IsAvailableInBothPhases(string baseName)
|
||||
{
|
||||
return baseName.StartsWith("사전_역대당선", StringComparison.Ordinal) ||
|
||||
return ScheduleTemplatePolicy.IsTitleFormat(baseName) ||
|
||||
baseName.StartsWith("사전_역대당선", StringComparison.Ordinal) ||
|
||||
baseName.StartsWith("경력_", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,11 @@ public interface ITornado3Adapter
|
||||
|
||||
Task PrepareAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
|
||||
Task ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
|
||||
Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
|
||||
Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
|
||||
Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
12
Tornado3_2026Election/Services/KarismaCropKeyUpdate.cs
Normal file
12
Tornado3_2026Election/Services/KarismaCropKeyUpdate.cs
Normal 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);
|
||||
@@ -332,6 +332,7 @@ public class KarismaEventHandler : KAEventHandler
|
||||
|
||||
completion.TrySetException(error);
|
||||
}
|
||||
|
||||
public void OnLoadScene(eKResult Result, string SceneName)
|
||||
{
|
||||
LogResult(nameof(OnLoadScene), Result, $"scene={SceneName}");
|
||||
@@ -510,7 +511,7 @@ public class KarismaEventHandler : KAEventHandler
|
||||
virtual public void OnSetCylinderAngleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetSphereAngleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCircleAngleKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) { }
|
||||
public void OnSetCropKey(eKResult Result, string SceneName, string ObjectName) => LogResult(nameof(OnSetCropKey), Result, $"scene={SceneName} object={ObjectName}");
|
||||
virtual public void OnSetCountDown(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetPosition(eKResult Result, string SceneName, string ObjectName) { }
|
||||
virtual public void OnSetRotation(eKResult Result, string SceneName, string ObjectName) { }
|
||||
@@ -674,6 +675,65 @@ public class KarismaEventHandler : KAEventHandler
|
||||
completion?.TrySetResult(errorCode);
|
||||
}
|
||||
|
||||
private static void CancelPendingSceneOperation(
|
||||
object syncRoot,
|
||||
Dictionary<string, TaskCompletionSource<eKResult>> pendingOperations,
|
||||
string sceneName,
|
||||
Exception? error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sceneName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TaskCompletionSource<eKResult>? completion;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (!pendingOperations.TryGetValue(sceneName, out completion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingOperations.Remove(sceneName);
|
||||
}
|
||||
|
||||
CompleteOrCancel(completion, error);
|
||||
}
|
||||
|
||||
private static void CompletePendingSceneOperation(
|
||||
object syncRoot,
|
||||
Dictionary<string, TaskCompletionSource<eKResult>> pendingOperations,
|
||||
string sceneName,
|
||||
eKResult result)
|
||||
{
|
||||
TaskCompletionSource<eKResult>? completion = null;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(sceneName) &&
|
||||
pendingOperations.TryGetValue(sceneName, out completion))
|
||||
{
|
||||
pendingOperations.Remove(sceneName);
|
||||
}
|
||||
else if (pendingOperations.Count == 1)
|
||||
{
|
||||
string? keyToRemove = null;
|
||||
foreach (var pair in pendingOperations)
|
||||
{
|
||||
keyToRemove = pair.Key;
|
||||
completion = pair.Value;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyToRemove))
|
||||
{
|
||||
pendingOperations.Remove(keyToRemove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completion?.TrySetResult(result);
|
||||
}
|
||||
|
||||
private static void CompleteOrCancel<TResult>(TaskCompletionSource<TResult>? completion, Exception? error)
|
||||
{
|
||||
if (completion is null)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using Tornado3_2026Election.Domain;
|
||||
|
||||
namespace Tornado3_2026Election.Services;
|
||||
@@ -7,6 +8,12 @@ namespace Tornado3_2026Election.Services;
|
||||
public sealed class LogService
|
||||
{
|
||||
private const int MaxEntries = 400;
|
||||
private const long MaxDebugLogBytes = 2 * 1024 * 1024;
|
||||
private static readonly object FileSync = new();
|
||||
private static readonly string DebugLogPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Tornado3_2026Election",
|
||||
"debug.log");
|
||||
|
||||
public ObservableCollection<LogEntry> Entries { get; } = [];
|
||||
|
||||
@@ -20,6 +27,8 @@ public sealed class LogService
|
||||
|
||||
private void Add(LogLevel level, string message)
|
||||
{
|
||||
WriteDebugLog(level, message);
|
||||
|
||||
Common.UiDispatcher.Enqueue(() =>
|
||||
{
|
||||
Entries.Insert(0, new LogEntry
|
||||
@@ -35,4 +44,32 @@ public sealed class LogService
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void WriteDebugLog(LogLevel level, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (FileSync)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(DebugLogPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
if (File.Exists(DebugLogPath) && new FileInfo(DebugLogPath).Length > MaxDebugLogBytes)
|
||||
{
|
||||
File.Delete(DebugLogPath);
|
||||
}
|
||||
|
||||
File.AppendAllText(
|
||||
DebugLogPath,
|
||||
$"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff zzz} [{level}] {message}{Environment.NewLine}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logging must not affect live CG control.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ShowPreparedFirstFrameAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
{
|
||||
State = TornadoConnectionState.Ready;
|
||||
await Task.Delay(40, cancellationToken).ConfigureAwait(false);
|
||||
_logService.Info($"[{channel}] Show prepared first frame on PGM");
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task TakeAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
@@ -121,6 +131,16 @@ public sealed class MockTornado3Adapter : ITornado3Adapter
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task ClearOutputAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
{
|
||||
State = TornadoConnectionState.Idle;
|
||||
await Task.Delay(30, cancellationToken).ConfigureAwait(false);
|
||||
_logService.Info($"[{channel}] Clear output layer");
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task OutAsync(BroadcastChannel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
await ExecuteWithTimeoutAsync(async () =>
|
||||
|
||||
@@ -156,7 +156,12 @@ internal static class PartyColorCatalog
|
||||
|
||||
var folderName = Path.GetFileName(Path.GetFullPath(templateFolderPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
if (TryGetExplicitRgbSpecBaseName(folderName, templateName, out var explicitSpecBaseName) &&
|
||||
!string.IsNullOrWhiteSpace(explicitSpecBaseName))
|
||||
string.IsNullOrWhiteSpace(explicitSpecBaseName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(explicitSpecBaseName))
|
||||
{
|
||||
var explicitSpecPath = Path.Combine(rgbDirectoryPath, explicitSpecBaseName + ".txt");
|
||||
if (File.Exists(explicitSpecPath))
|
||||
@@ -265,6 +270,11 @@ internal static class PartyColorCatalog
|
||||
|
||||
if (line.StartsWith("(", StringComparison.Ordinal))
|
||||
{
|
||||
if (inHeader)
|
||||
{
|
||||
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
|
||||
}
|
||||
|
||||
headerBuilder.Clear();
|
||||
headerBuilder.AppendLine(line);
|
||||
inHeader = !line.Contains(')');
|
||||
@@ -278,14 +288,20 @@ internal static class PartyColorCatalog
|
||||
|
||||
if (inHeader)
|
||||
{
|
||||
headerBuilder.AppendLine(line);
|
||||
if (line.Contains(')'))
|
||||
if (IsHeaderContinuationLine(line))
|
||||
{
|
||||
inHeader = false;
|
||||
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
|
||||
headerBuilder.AppendLine(line);
|
||||
if (line.Contains(')'))
|
||||
{
|
||||
inHeader = false;
|
||||
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
inHeader = false;
|
||||
currentSectionHeaders = ExtractSectionHeaders(headerBuilder.ToString());
|
||||
}
|
||||
|
||||
if (currentSectionHeaders is null || currentSectionHeaders.Count == 0 || line.StartsWith("R", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -328,6 +344,17 @@ internal static class PartyColorCatalog
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsHeaderContinuationLine(string line)
|
||||
{
|
||||
if (line.Contains(')'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalizedLine = line.Trim().Trim('(', ')').Trim();
|
||||
return TryParseSectionHeaderLine(normalizedLine, out _, out _);
|
||||
}
|
||||
|
||||
private static List<SectionHeaderEntry> ExtractSectionHeaders(string header)
|
||||
{
|
||||
var entries = new List<SectionHeaderEntry>();
|
||||
@@ -871,7 +898,12 @@ internal static class PartyColorCatalog
|
||||
"Elect2026_Normal_민방",
|
||||
"이시각1위_광역단체장",
|
||||
"이시각1위_광역단체장",
|
||||
"이시각1위_광역단체장_HD",
|
||||
"이시각1위_광역단체장_HD");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
"이시각1위_광역단체장_5760",
|
||||
"이시각1위_광역단체장_5760",
|
||||
"이시각1위_광역단체장_L");
|
||||
Add(
|
||||
mappings,
|
||||
@@ -894,8 +926,15 @@ internal static class PartyColorCatalog
|
||||
"판세_광역단체장",
|
||||
"판세_광역단체장",
|
||||
"판세_기초단체장",
|
||||
"역대시도판세_광역단체장",
|
||||
"역대시도판세_기초단체장",
|
||||
"판세_기초단체장_5760",
|
||||
"판세_기초단체장_7680");
|
||||
Add(
|
||||
mappings,
|
||||
"Elect2026_Normal_민방",
|
||||
string.Empty,
|
||||
"사전_역대투표율");
|
||||
|
||||
Add(
|
||||
mappings,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
private ScheduleRegionOption? _selectedRegionOption;
|
||||
private SelectionOption<EmptyScheduleBehavior>? _selectedEmptyBehaviorOption;
|
||||
private CancellationTokenSource? _directPlaybackCts;
|
||||
private ChannelScheduleItem? _preparedDirectItem;
|
||||
private string _preparedDirectFormatId = string.Empty;
|
||||
private string _preparedDirectRegionKey = string.Empty;
|
||||
private bool _loopEnabled;
|
||||
private EmptyScheduleBehavior _emptyScheduleBehavior = EmptyScheduleBehavior.ImmediateOut;
|
||||
private int _regionOptionsRevision;
|
||||
@@ -81,8 +84,10 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
];
|
||||
Queue = engine.Queue;
|
||||
|
||||
SchedulePrepareCommand = new AsyncRelayCommand(PrepareScheduleAsync);
|
||||
StartCommand = new AsyncRelayCommand(StartAsync, allowConcurrentExecutions: true);
|
||||
StopCommand = new AsyncRelayCommand(StopAsync);
|
||||
DirectPrepareCommand = new AsyncRelayCommand(DirectPrepareAsync, CanDirectStart);
|
||||
DirectStartCommand = new AsyncRelayCommand(DirectStartAsync, CanDirectStart, allowConcurrentExecutions: true);
|
||||
DirectStopCommand = new AsyncRelayCommand(DirectStopAsync);
|
||||
ForceNextCommand = new AsyncRelayCommand(ForceNextAsync);
|
||||
@@ -141,10 +146,14 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public ObservableCollection<ChannelScheduleItem> Queue { get; }
|
||||
|
||||
public AsyncRelayCommand SchedulePrepareCommand { get; }
|
||||
|
||||
public AsyncRelayCommand StartCommand { get; }
|
||||
|
||||
public AsyncRelayCommand StopCommand { get; }
|
||||
|
||||
public AsyncRelayCommand DirectPrepareCommand { get; }
|
||||
|
||||
public AsyncRelayCommand DirectStartCommand { get; }
|
||||
|
||||
public AsyncRelayCommand DirectStopCommand { get; }
|
||||
@@ -230,6 +239,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
SyncSelectedCutDebugTemplate();
|
||||
_ = RebuildRegionOptionsAsync();
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
@@ -243,6 +253,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
if (SetProperty(ref _selectedRegionOption, value))
|
||||
{
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
@@ -332,7 +343,9 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
|
||||
public Brush PlaybackIconBrush => IsPlaying ? PlaybackActiveIconBrush : PlaybackIdleIconBrush;
|
||||
|
||||
public string TransmissionLabel => IsPlaying
|
||||
public string TransmissionLabel => _engine.ActivePlaybackItem?.State == ScheduleQueueItemState.Sending
|
||||
? "준비"
|
||||
: IsPlaying
|
||||
? "송출 중"
|
||||
: "대기";
|
||||
|
||||
@@ -519,6 +532,21 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
_logService.Info($"[{Title}] 큐를 시작");
|
||||
}
|
||||
|
||||
private async Task PrepareScheduleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _engine.PrepareNextAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 스케줄 다음 컷 준비");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
RefreshSummary();
|
||||
_logService.Error($"[{Title}] Schedule prepare failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopAsync()
|
||||
{
|
||||
await _engine.StopAsync().ConfigureAwait(false);
|
||||
@@ -526,6 +554,57 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
_logService.Info($"[{Title}] 큐를 종료");
|
||||
}
|
||||
|
||||
private async Task DirectPrepareAsync()
|
||||
{
|
||||
var selectedFormat = SelectedFormat;
|
||||
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
||||
if (selectedFormat is null || regionOption is null)
|
||||
{
|
||||
_logService.Warning($"[{Title}] 바로 송출 준비할 컷과 지역을 먼저 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
|
||||
_directPlaybackCts?.Cancel();
|
||||
_directPlaybackCts?.Dispose();
|
||||
|
||||
var prepareCts = new CancellationTokenSource();
|
||||
_directPlaybackCts = prepareCts;
|
||||
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
||||
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
|
||||
selectedFormat,
|
||||
_videoWallLayoutPreset,
|
||||
ThumbnailDisplayContext.Queue));
|
||||
_preparedDirectItem = item;
|
||||
_preparedDirectFormatId = selectedFormat.Id;
|
||||
_preparedDirectRegionKey = BuildRegionOptionKey(regionOption);
|
||||
|
||||
try
|
||||
{
|
||||
_logService.Info($"[{Title}] 선택 컷 준비: {selectedFormat.Name} / {regionOption.Label}");
|
||||
await _engine.PrepareDirectAsync(item, selectedFormat, prepareCts.Token).ConfigureAwait(false);
|
||||
if (!prepareCts.IsCancellationRequested)
|
||||
{
|
||||
_logService.Info($"[{Title}] 선택 컷 준비 완료: {selectedFormat.Name}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
ClearPreparedDirectState(item);
|
||||
_logService.Error($"[{Title}] 선택 컷 준비 실패: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ReferenceEquals(_directPlaybackCts, prepareCts))
|
||||
{
|
||||
_directPlaybackCts = null;
|
||||
}
|
||||
|
||||
prepareCts.Dispose();
|
||||
RefreshSummary();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DirectStartAsync()
|
||||
{
|
||||
var selectedFormat = SelectedFormat;
|
||||
@@ -536,22 +615,24 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
var preparedItem = ResolvePreparedDirectItem(selectedFormat, regionOption);
|
||||
if (preparedItem is null)
|
||||
{
|
||||
_logService.Warning($"[{Title}] 현재 단계에서는 '{selectedFormat.Name}' 컷을 바로 송출할 수 없습니다.");
|
||||
return;
|
||||
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
|
||||
_directPlaybackCts?.Cancel();
|
||||
_directPlaybackCts?.Dispose();
|
||||
}
|
||||
|
||||
await _engine.StopAsync(takeOutputOff: false).ConfigureAwait(false);
|
||||
_directPlaybackCts?.Cancel();
|
||||
|
||||
var playbackCts = new CancellationTokenSource();
|
||||
_directPlaybackCts = playbackCts;
|
||||
var item = ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
||||
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
|
||||
selectedFormat,
|
||||
_videoWallLayoutPreset,
|
||||
ThumbnailDisplayContext.Queue));
|
||||
var item = preparedItem ?? ChannelScheduleItem.FromTemplate(selectedFormat, regionOption);
|
||||
if (preparedItem is null)
|
||||
{
|
||||
item.UpdateThumbnailLayout(ThumbnailLayoutResolver.ResolveDisplayMetrics(
|
||||
selectedFormat,
|
||||
_videoWallLayoutPreset,
|
||||
ThumbnailDisplayContext.Queue));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -568,6 +649,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
}
|
||||
finally
|
||||
{
|
||||
ClearPreparedDirectState(item);
|
||||
if (ReferenceEquals(_directPlaybackCts, playbackCts))
|
||||
{
|
||||
_directPlaybackCts = null;
|
||||
@@ -582,10 +664,49 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
{
|
||||
_directPlaybackCts?.Cancel();
|
||||
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
|
||||
ClearPreparedDirectState(_preparedDirectItem);
|
||||
_engine.ClearDirectPlayback();
|
||||
RefreshSummary();
|
||||
_logService.Info($"[{Title}] 선택 컷 송출 정지");
|
||||
}
|
||||
|
||||
private ChannelScheduleItem? ResolvePreparedDirectItem(
|
||||
FormatTemplateDefinition selectedFormat,
|
||||
ScheduleRegionOption regionOption)
|
||||
{
|
||||
if (_preparedDirectItem is null ||
|
||||
!_engine.IsPreparedItem(_preparedDirectItem) ||
|
||||
!string.Equals(_preparedDirectFormatId, selectedFormat.Id, StringComparison.Ordinal) ||
|
||||
!string.Equals(_preparedDirectRegionKey, BuildRegionOptionKey(regionOption), StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _preparedDirectItem;
|
||||
}
|
||||
|
||||
private void ClearPreparedDirectState(ChannelScheduleItem? item)
|
||||
{
|
||||
if (item is not null && !ReferenceEquals(_preparedDirectItem, item))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_preparedDirectItem = null;
|
||||
_preparedDirectFormatId = string.Empty;
|
||||
_preparedDirectRegionKey = string.Empty;
|
||||
}
|
||||
|
||||
private static string BuildRegionOptionKey(ScheduleRegionOption regionOption)
|
||||
{
|
||||
return string.Join(
|
||||
"\u001F",
|
||||
regionOption.Scope,
|
||||
regionOption.ElectionType ?? string.Empty,
|
||||
regionOption.Label ?? string.Empty,
|
||||
regionOption.DistrictCode ?? string.Empty);
|
||||
}
|
||||
|
||||
private async Task ForceNextAsync()
|
||||
{
|
||||
await _engine.ForceNextAsync().ConfigureAwait(false);
|
||||
@@ -607,12 +728,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
{
|
||||
_logService.Warning($"[{Title}] 현재 단계에서는 '{SelectedFormat.Name}' 컷을 추가할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
var regionOption = SelectedRegionOption ?? RegionOptions.FirstOrDefault();
|
||||
if (regionOption is null)
|
||||
{
|
||||
@@ -745,7 +860,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
private bool CanAddFormat()
|
||||
{
|
||||
return SelectedFormat is not null &&
|
||||
SelectedFormat.IsAvailableInPhase(_data.BroadcastPhase) &&
|
||||
SelectedRegionOption is not null;
|
||||
}
|
||||
|
||||
@@ -770,7 +884,6 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
var selectedFormatId = SelectedFormat?.Id;
|
||||
var selectedCategory = SelectedFormatCategoryOption?.Value;
|
||||
var filteredFormats = _allFormats
|
||||
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
.Where(format => selectedCategory is null || CutCategoryResolver.IsMatch(format, selectedCategory.Value))
|
||||
.ToArray();
|
||||
|
||||
@@ -792,6 +905,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
UpdateSelectedFormatThumbnailMetrics();
|
||||
SyncSelectedCutDebugTemplate();
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(QueueFootnote));
|
||||
}
|
||||
@@ -799,10 +913,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
private void RebuildFormatCategoryOptions()
|
||||
{
|
||||
var selectedCategory = SelectedFormatCategoryOption?.Value;
|
||||
var formatsInCurrentPhase = _allFormats
|
||||
.Where(format => format.IsAvailableInPhase(_data.BroadcastPhase))
|
||||
.ToArray();
|
||||
var options = CreateFormatCategoryOptions(formatsInCurrentPhase);
|
||||
var options = CreateFormatCategoryOptions(_allFormats);
|
||||
|
||||
FormatCategoryOptions.Clear();
|
||||
foreach (var option in options)
|
||||
@@ -871,6 +982,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
SelectedRegionOption = null;
|
||||
_lastRegionOptionFormatId = string.Empty;
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
return;
|
||||
}
|
||||
@@ -898,6 +1010,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
||||
SelectedRegionOption = ResolvePreferredRegionOption(options, previousSelection, selectedFormat, shouldUseDefaultSelection);
|
||||
_lastRegionOptionFormatId = selectedFormat.Id;
|
||||
AddFormatCommand.NotifyCanExecuteChanged();
|
||||
DirectPrepareCommand.NotifyCanExecuteChanged();
|
||||
DirectStartCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -1141,7 +1141,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
return await GetTurnoutPhotoRegionLevelOptionsAsync(turnoutPhotoMode, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
||||
var options = await GetScheduleDistrictOptionsAsync(electionType, template, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var regionOptions = new List<ScheduleRegionOption>
|
||||
{
|
||||
@@ -1159,7 +1159,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
}
|
||||
};
|
||||
|
||||
if (IsByElectionTemplate(template) || ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name))
|
||||
if (IsByElectionTemplate(template) ||
|
||||
IsNormalPreElectionTurnoutDistrictBoardTemplate(template) ||
|
||||
ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template?.Name))
|
||||
{
|
||||
regionOptions.AddRange(CreateScheduleRegionGroupOptions(options, electionType));
|
||||
}
|
||||
@@ -1522,7 +1524,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
|
||||
private BroadcastPhase ResolveScheduleRefreshPhase(FormatTemplateDefinition template)
|
||||
{
|
||||
return BroadcastPhase == BroadcastPhase.PreElection && IsCareerTemplate(template)
|
||||
if (IsTurnoutTemplate(template))
|
||||
{
|
||||
return BroadcastPhase.PreElection;
|
||||
}
|
||||
|
||||
return template.RequiresCandidateData || IsCareerTemplate(template)
|
||||
? BroadcastPhase.Counting
|
||||
: BroadcastPhase;
|
||||
}
|
||||
@@ -1541,9 +1548,9 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
|
||||
private bool ShouldUseTurnoutPhotoRegionLevelOptions(FormatTemplateDefinition? template)
|
||||
{
|
||||
return BroadcastPhase == BroadcastPhase.PreElection &&
|
||||
template is not null &&
|
||||
string.Equals(template.Name, "투표율_사진", StringComparison.Ordinal);
|
||||
return template is not null &&
|
||||
(string.Equals(template.Name, "투표율", StringComparison.Ordinal) ||
|
||||
string.Equals(template.Name, "투표율_사진", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static int ResolveRequiredCandidateCount(FormatTemplateDefinition template)
|
||||
@@ -1616,7 +1623,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
return [CreateSingleScheduleRegionTarget(electionType)];
|
||||
}
|
||||
|
||||
var options = await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
||||
var options = await GetScheduleDistrictOptionsAsync(electionType, template, cancellationToken).ConfigureAwait(false);
|
||||
if (options.Count == 0)
|
||||
{
|
||||
return Array.Empty<ScheduleRegionTarget>();
|
||||
@@ -1629,6 +1636,26 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template) &&
|
||||
item.RegionScope == ScheduleRegionScope.Single)
|
||||
{
|
||||
var regionGroupTargets = ResolveRegionGroupTargets(item, options, electionType);
|
||||
if (regionGroupTargets.Count > 1)
|
||||
{
|
||||
return regionGroupTargets;
|
||||
}
|
||||
}
|
||||
|
||||
if (UsesHistoricalScheduleOptions(template) &&
|
||||
item.RegionScope is ScheduleRegionScope.Single or ScheduleRegionScope.RegionGroup)
|
||||
{
|
||||
var splitHistoricalTargets = ResolveCombinedHistoricalRegionTargets(item, options, electionType);
|
||||
if (splitHistoricalTargets.Count > 0)
|
||||
{
|
||||
return splitHistoricalTargets;
|
||||
}
|
||||
}
|
||||
|
||||
return item.RegionScope switch
|
||||
{
|
||||
ScheduleRegionScope.StationRegions => ResolveStationRegionTargets(options, electionType, station),
|
||||
@@ -1669,16 +1696,18 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
[target],
|
||||
includeNationalSlot: false,
|
||||
includeRegionalBoardSlots: false,
|
||||
includeEmptyRegionalBoardSlots: false,
|
||||
maxRegionalSlots: 0,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var refreshPhase = ResolveScheduleRefreshPhase(template);
|
||||
var refreshResult = await _apiClient
|
||||
.RefreshAsync(ResolveScheduleRefreshPhase(template), electionType, target.DisplayName, target.DistrictCode, cancellationToken)
|
||||
.RefreshAsync(refreshPhase, electionType, target.DisplayName, target.DistrictCode, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return CreateSnapshotFromRefreshResult(electionType, refreshResult);
|
||||
return CreateSnapshotFromRefreshResult(electionType, refreshResult, refreshPhase);
|
||||
}
|
||||
|
||||
public Task<ElectionDataSnapshot> GetAggregateScheduleSnapshotAsync(
|
||||
@@ -1714,6 +1743,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
regionTargets,
|
||||
includeNationalSlot: true,
|
||||
includeRegionalBoardSlots: true,
|
||||
includeEmptyRegionalBoardSlots: false,
|
||||
maxRegionalSlots: 4,
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -1725,6 +1755,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
regionTargets,
|
||||
includeNationalSlot: false,
|
||||
includeRegionalBoardSlots: true,
|
||||
includeEmptyRegionalBoardSlots: IsNormalPreElectionTurnoutDistrictBoardTemplate(template),
|
||||
maxRegionalSlots: ResolveRegionalTurnoutBoardMaxSlots(template),
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -3640,6 +3671,77 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetScheduleDistrictOptionsAsync(
|
||||
string electionType,
|
||||
FormatTemplateDefinition? template,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (UsesHistoricalScheduleOptions(template))
|
||||
{
|
||||
var historicalOptions = GetHistoricalScheduleDistrictOptions(electionType);
|
||||
if (historicalOptions.Count > 0)
|
||||
{
|
||||
return historicalOptions;
|
||||
}
|
||||
}
|
||||
|
||||
return await GetScheduleDistrictOptionsAsync(electionType, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> GetHistoricalScheduleDistrictOptions(string electionType)
|
||||
{
|
||||
var records = _preElectionHistoryService.GetSelectionRecords(electionType);
|
||||
if (records.Count == 0)
|
||||
{
|
||||
return Array.Empty<SbsElectionApiClient.DistrictSelectionOption>();
|
||||
}
|
||||
|
||||
return records
|
||||
.Where(record => !string.IsNullOrWhiteSpace(ResolveHistoricalScheduleOptionDisplayName(record)))
|
||||
.OrderBy(record => ResolveDefaultRegionOrder(record.RegionName))
|
||||
.ThenBy(record => ResolveHistoricalScheduleOptionDisplayName(record), StringComparer.Ordinal)
|
||||
.Select(CreateHistoricalScheduleDistrictOption)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static SbsElectionApiClient.DistrictSelectionOption CreateHistoricalScheduleDistrictOption(
|
||||
PreElectionHistoryRecord record)
|
||||
{
|
||||
var regionName = string.IsNullOrWhiteSpace(record.RegionName)
|
||||
? record.DisplayName
|
||||
: record.RegionName;
|
||||
var districtName = string.IsNullOrWhiteSpace(record.DistrictName)
|
||||
? ResolveHistoricalScheduleOptionDisplayName(record)
|
||||
: record.DistrictName;
|
||||
var parentRegionCode = SbsElectionApiClient.ResolveBasicApiSidoCode(regionName);
|
||||
var canonicalElectionType = PreElectionHistoryService.NormalizeElectionType(record.ElectionType);
|
||||
var districtCode = string.Equals(canonicalElectionType, "기초단체장", StringComparison.Ordinal)
|
||||
? record.Key
|
||||
: parentRegionCode;
|
||||
|
||||
return new SbsElectionApiClient.DistrictSelectionOption(
|
||||
DisplayName: ResolveHistoricalScheduleOptionDisplayName(record),
|
||||
DistrictCode: districtCode,
|
||||
RegionName: regionName,
|
||||
DistrictName: districtName,
|
||||
ParentRegionCode: parentRegionCode);
|
||||
}
|
||||
|
||||
private static string ResolveHistoricalScheduleOptionDisplayName(PreElectionHistoryRecord record)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(record.DisplayName))
|
||||
{
|
||||
return record.DisplayName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(record.DistrictName))
|
||||
{
|
||||
return record.DistrictName;
|
||||
}
|
||||
|
||||
return record.RegionName ?? string.Empty;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetScheduleDistrictOptionsAsync(
|
||||
string electionType,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -3696,6 +3798,21 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
return "기초단체장";
|
||||
}
|
||||
|
||||
if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
|
||||
{
|
||||
return "기초단체장";
|
||||
}
|
||||
|
||||
if (template is not null && ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
|
||||
{
|
||||
return "광역단체장";
|
||||
}
|
||||
|
||||
if (template is not null && IsTurnoutTemplate(template))
|
||||
{
|
||||
return ResolvePreElectionTurnoutElectionType(preferredElectionType);
|
||||
}
|
||||
|
||||
return ResolveScheduleElectionType(template?.Name, preferredElectionType);
|
||||
}
|
||||
|
||||
@@ -3707,6 +3824,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
return historicalWinnerElectionType;
|
||||
}
|
||||
|
||||
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(resolvedFormatName))
|
||||
{
|
||||
return "광역단체장";
|
||||
}
|
||||
|
||||
if (IsBottomTurnoutDistrictTemplateName(resolvedFormatName))
|
||||
{
|
||||
return "기초단체장";
|
||||
@@ -3749,11 +3871,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
|
||||
if (BroadcastPhase == BroadcastPhase.PreElection)
|
||||
{
|
||||
return SupportsPreElectionTurnout(electionType: preferredElectionType)
|
||||
? preferredElectionType!
|
||||
: SupportsPreElectionTurnout(ElectionType)
|
||||
? ElectionType
|
||||
: "광역단체장";
|
||||
return ResolvePreElectionTurnoutElectionType(preferredElectionType);
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(preferredElectionType)
|
||||
@@ -3761,6 +3879,15 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
: preferredElectionType;
|
||||
}
|
||||
|
||||
private string ResolvePreElectionTurnoutElectionType(string? preferredElectionType = null)
|
||||
{
|
||||
return SupportsPreElectionTurnout(electionType: preferredElectionType)
|
||||
? preferredElectionType!
|
||||
: SupportsPreElectionTurnout(ElectionType)
|
||||
? ElectionType
|
||||
: "광역단체장";
|
||||
}
|
||||
|
||||
private static bool TryResolveHistoricalWinnerElectionType(string formatName, out string electionType)
|
||||
{
|
||||
var sourceName = System.IO.Path.GetFileNameWithoutExtension(
|
||||
@@ -3839,6 +3966,38 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ScheduleRegionTarget> ResolveCombinedHistoricalRegionTargets(
|
||||
ChannelScheduleItem item,
|
||||
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> options,
|
||||
string electionType)
|
||||
{
|
||||
if (!IsGwangjuJeonnamCombinedLabel(item.RegionLabel))
|
||||
{
|
||||
return Array.Empty<ScheduleRegionTarget>();
|
||||
}
|
||||
|
||||
return options
|
||||
.Where(option => GetNormalizedRegionKeys(option.RegionName)
|
||||
.Any(key => string.Equals(key, "광주", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(key, "전남", StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(option => ResolveDefaultRegionOrder(option.RegionName))
|
||||
.Select(option => CreateScheduleRegionTarget(option, electionType))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool UsesHistoricalScheduleOptions(FormatTemplateDefinition? template)
|
||||
{
|
||||
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template?.Name) ||
|
||||
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template?.Name);
|
||||
}
|
||||
|
||||
private static bool IsGwangjuJeonnamCombinedLabel(string? value)
|
||||
{
|
||||
var normalized = NormalizeConfiguredRegion(value);
|
||||
return string.Equals(normalized, "전남광주", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(normalized, "광주전남", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool MatchesScheduleRegionGroup(
|
||||
SbsElectionApiClient.DistrictSelectionOption option,
|
||||
string regionCode,
|
||||
@@ -3873,7 +4032,8 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
|
||||
private ElectionDataSnapshot CreateSnapshotFromRefreshResult(
|
||||
string electionType,
|
||||
SbsElectionApiClient.SbsElectionRefreshResult refreshResult)
|
||||
SbsElectionApiClient.SbsElectionRefreshResult refreshResult,
|
||||
BroadcastPhase broadcastPhase)
|
||||
{
|
||||
var districtName = string.IsNullOrWhiteSpace(refreshResult.DistrictName)
|
||||
? refreshResult.ElectionDistrictName
|
||||
@@ -3888,7 +4048,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
|
||||
return new ElectionDataSnapshot
|
||||
{
|
||||
BroadcastPhase = BroadcastPhase,
|
||||
BroadcastPhase = broadcastPhase,
|
||||
ElectionType = electionType,
|
||||
DistrictName = districtName ?? string.Empty,
|
||||
DistrictCode = refreshResult.DistrictCode ?? string.Empty,
|
||||
@@ -4682,6 +4842,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
IReadOnlyList<ScheduleRegionTarget> regionTargets,
|
||||
bool includeNationalSlot,
|
||||
bool includeRegionalBoardSlots,
|
||||
bool includeEmptyRegionalBoardSlots,
|
||||
int maxRegionalSlots,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -4722,17 +4883,18 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
foreach (var target in selectedTargets.Take(Math.Max(maxRegionalSlots, 0)))
|
||||
{
|
||||
var slotItem = FindTurnoutOverviewItem(turnoutOverview.Items, target);
|
||||
if (!HasPositiveTurnoutOverview(slotItem))
|
||||
var hasTurnoutData = HasPositiveTurnoutOverview(slotItem);
|
||||
if (!includeEmptyRegionalBoardSlots && !hasTurnoutData)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolvedSlotItem = slotItem!;
|
||||
turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(
|
||||
nextSlot++,
|
||||
ResolveTurnoutBoardDistrictLabel(electionType, resolvedSlotItem, target),
|
||||
resolvedSlotItem.TurnoutRate,
|
||||
RegionLabel: ResolveTurnoutBoardRegionLabel(resolvedSlotItem, target)));
|
||||
ResolveTurnoutBoardDistrictLabel(electionType, slotItem, target),
|
||||
hasTurnoutData ? slotItem!.TurnoutRate : 0d,
|
||||
RegionLabel: ResolveTurnoutBoardRegionLabel(slotItem, target),
|
||||
HasTurnoutData: hasTurnoutData));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4757,7 +4919,7 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
|
||||
return new ElectionDataSnapshot
|
||||
{
|
||||
BroadcastPhase = BroadcastPhase,
|
||||
BroadcastPhase = BroadcastPhase.PreElection,
|
||||
ElectionType = electionType,
|
||||
DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName,
|
||||
DistrictCode = primaryItem?.DistrictCode ?? primaryTarget?.DistrictCode ?? string.Empty,
|
||||
@@ -4806,17 +4968,17 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
|
||||
private static string ResolveTurnoutBoardDistrictLabel(
|
||||
string electionType,
|
||||
SbsElectionApiClient.TurnoutOverviewItem item,
|
||||
SbsElectionApiClient.TurnoutOverviewItem? item,
|
||||
ScheduleRegionTarget target)
|
||||
{
|
||||
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
|
||||
{
|
||||
return FirstNonWhiteSpace(
|
||||
item.DistrictName,
|
||||
item?.DistrictName,
|
||||
target.DistrictName,
|
||||
item.DisplayName,
|
||||
item?.DisplayName,
|
||||
target.DisplayName,
|
||||
item.RegionName,
|
||||
item?.RegionName,
|
||||
target.RegionName);
|
||||
}
|
||||
|
||||
@@ -4824,13 +4986,13 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
}
|
||||
|
||||
private static string ResolveTurnoutBoardRegionLabel(
|
||||
SbsElectionApiClient.TurnoutOverviewItem item,
|
||||
SbsElectionApiClient.TurnoutOverviewItem? item,
|
||||
ScheduleRegionTarget target)
|
||||
{
|
||||
return FirstNonWhiteSpace(
|
||||
item.RegionName,
|
||||
item?.RegionName,
|
||||
target.RegionName,
|
||||
item.DisplayName,
|
||||
item?.DisplayName,
|
||||
target.DisplayName);
|
||||
}
|
||||
|
||||
@@ -4942,6 +5104,12 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
||||
IsTopTurnoutDistrictBoardTemplate(template);
|
||||
}
|
||||
|
||||
private static bool IsNormalPreElectionTurnoutDistrictBoardTemplate(FormatTemplateDefinition? template)
|
||||
{
|
||||
return template?.RecommendedChannel == BroadcastChannel.Normal &&
|
||||
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsTopTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
|
||||
{
|
||||
return template.RecommendedChannel == BroadcastChannel.TopLeft &&
|
||||
|
||||
BIN
outputs/color-audit/color_match_audit_list.xlsx
Normal file
BIN
outputs/color-audit/color_match_audit_list.xlsx
Normal file
Binary file not shown.
@@ -155,7 +155,9 @@ internal static class CurrentApiCutDiagnostics
|
||||
districtCache,
|
||||
electionType,
|
||||
station,
|
||||
options.RegionScope == "all" || IsNormalPanseMapTemplate(template))
|
||||
options.RegionScope == "all" || IsNormalPanseMapTemplate(template),
|
||||
template,
|
||||
preElectionHistoryService)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -620,6 +622,10 @@ internal static class CurrentApiCutDiagnostics
|
||||
? overview.TurnoutVotes
|
||||
: primaryItem?.TurnoutVotes ?? 0;
|
||||
|
||||
var snapshotReferenceTimeLabel = includeNationalSlot
|
||||
? overview.ReferenceTimeLabel
|
||||
: FirstNonWhiteSpace(primaryItem?.ReferenceTimeLabel, overview.ReferenceTimeLabel);
|
||||
|
||||
return new ElectionDataSnapshot
|
||||
{
|
||||
BroadcastPhase = BroadcastPhase.PreElection,
|
||||
@@ -634,7 +640,8 @@ internal static class CurrentApiCutDiagnostics
|
||||
CountedVotesFromApi = null,
|
||||
RemainingVotesFromApi = null,
|
||||
CountedRateFromApi = null,
|
||||
ReceivedAt = DateTimeOffset.Now,
|
||||
ReceivedAt = overview.ReceivedAt == default ? DateTimeOffset.Now : overview.ReceivedAt,
|
||||
ReferenceTimeLabel = snapshotReferenceTimeLabel,
|
||||
TurnoutBoardSlots = turnoutBoardSlots,
|
||||
NationalTurnoutRateOverride = overview.NationalTurnoutRate
|
||||
};
|
||||
@@ -1104,6 +1111,7 @@ internal static class CurrentApiCutDiagnostics
|
||||
RemainingVotesFromApi = null,
|
||||
CountedRateFromApi = null,
|
||||
ReceivedAt = overview.ReceivedAt == default ? DateTimeOffset.Now : overview.ReceivedAt,
|
||||
ReferenceTimeLabel = FirstNonWhiteSpace(item.ReferenceTimeLabel, overview.ReferenceTimeLabel),
|
||||
NationalTurnoutRateOverride = overview.NationalTurnoutRate
|
||||
};
|
||||
}
|
||||
@@ -1318,8 +1326,15 @@ internal static class CurrentApiCutDiagnostics
|
||||
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
|
||||
string electionType,
|
||||
BroadcastStationProfile station,
|
||||
bool useAllRegions = false)
|
||||
bool useAllRegions,
|
||||
FormatTemplateDefinition template,
|
||||
PreElectionHistoryService preElectionHistoryService)
|
||||
{
|
||||
if (UsesHistoricalStoredOptions(template))
|
||||
{
|
||||
return GetStoredHistoryDistricts(electionType, preElectionHistoryService);
|
||||
}
|
||||
|
||||
var regionFilters = useAllRegions ? Array.Empty<string>() : station.RegionFilters;
|
||||
var cacheKey = $"{electionType}|{string.Join(",", regionFilters)}";
|
||||
if (!districtCache.TryGetValue(cacheKey, out var districts))
|
||||
@@ -1333,6 +1348,59 @@ internal static class CurrentApiCutDiagnostics
|
||||
return districts;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> GetStoredHistoryDistricts(
|
||||
string electionType,
|
||||
PreElectionHistoryService preElectionHistoryService)
|
||||
{
|
||||
return preElectionHistoryService
|
||||
.GetSelectionRecords(electionType)
|
||||
.Where(record => !string.IsNullOrWhiteSpace(ResolveStoredHistoryDisplayName(record)))
|
||||
.OrderBy(record => SbsElectionApiClient.ResolveBasicApiSidoCode(record.RegionName), StringComparer.Ordinal)
|
||||
.ThenBy(record => ResolveStoredHistoryDisplayName(record), StringComparer.Ordinal)
|
||||
.Select(CreateStoredHistoryDistrict)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static SbsElectionApiClient.DistrictSelectionOption CreateStoredHistoryDistrict(
|
||||
PreElectionHistoryRecord record)
|
||||
{
|
||||
var regionName = string.IsNullOrWhiteSpace(record.RegionName)
|
||||
? record.DisplayName
|
||||
: record.RegionName;
|
||||
var districtName = string.IsNullOrWhiteSpace(record.DistrictName)
|
||||
? ResolveStoredHistoryDisplayName(record)
|
||||
: record.DistrictName;
|
||||
var parentRegionCode = SbsElectionApiClient.ResolveBasicApiSidoCode(regionName);
|
||||
var districtCode = string.Equals(
|
||||
PreElectionHistoryService.NormalizeElectionType(record.ElectionType),
|
||||
"기초단체장",
|
||||
StringComparison.Ordinal)
|
||||
? record.Key
|
||||
: parentRegionCode;
|
||||
|
||||
return new SbsElectionApiClient.DistrictSelectionOption(
|
||||
ResolveStoredHistoryDisplayName(record),
|
||||
districtCode,
|
||||
regionName,
|
||||
districtName,
|
||||
parentRegionCode);
|
||||
}
|
||||
|
||||
private static string ResolveStoredHistoryDisplayName(PreElectionHistoryRecord record)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(record.DisplayName))
|
||||
{
|
||||
return record.DisplayName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(record.DistrictName))
|
||||
{
|
||||
return record.DistrictName;
|
||||
}
|
||||
|
||||
return record.RegionName ?? string.Empty;
|
||||
}
|
||||
|
||||
private static IEnumerable<SbsElectionApiClient.DistrictSelectionOption> ResolveTargets(
|
||||
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts,
|
||||
BroadcastStationProfile station,
|
||||
@@ -1398,7 +1466,8 @@ internal static class CurrentApiCutDiagnostics
|
||||
CountedVotesFromApi = refreshResult.CountedVotes,
|
||||
RemainingVotesFromApi = refreshResult.RemainingVotes,
|
||||
CountedRateFromApi = refreshResult.CountedRate,
|
||||
ReceivedAt = refreshResult.ReceivedAt == default ? DateTimeOffset.Now : refreshResult.ReceivedAt
|
||||
ReceivedAt = refreshResult.ReceivedAt == default ? DateTimeOffset.Now : refreshResult.ReceivedAt,
|
||||
ReferenceTimeLabel = refreshResult.ReferenceTimeLabel
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2297,12 +2366,22 @@ internal static class CurrentApiCutDiagnostics
|
||||
return "기초단체장";
|
||||
}
|
||||
|
||||
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
|
||||
{
|
||||
return "광역단체장";
|
||||
}
|
||||
|
||||
return ResolveScheduleElectionType(template.Name, phase, defaultElectionType);
|
||||
}
|
||||
|
||||
private static string ResolveScheduleElectionType(string? formatName, BroadcastPhase phase, string defaultElectionType)
|
||||
{
|
||||
var resolvedFormatName = formatName ?? string.Empty;
|
||||
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(resolvedFormatName))
|
||||
{
|
||||
return "광역단체장";
|
||||
}
|
||||
|
||||
if (resolvedFormatName.Contains("교육감", StringComparison.Ordinal))
|
||||
{
|
||||
return "교육감";
|
||||
@@ -2343,6 +2422,12 @@ internal static class CurrentApiCutDiagnostics
|
||||
return defaultElectionType;
|
||||
}
|
||||
|
||||
private static bool UsesHistoricalStoredOptions(FormatTemplateDefinition template)
|
||||
{
|
||||
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) ||
|
||||
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name);
|
||||
}
|
||||
|
||||
private static bool SupportsPreElectionTurnout(string? electionType)
|
||||
{
|
||||
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||
|
||||
|
||||
@@ -274,6 +274,7 @@ internal static class CutFileAudit
|
||||
payload.CounterNumberKeys,
|
||||
Array.Empty<KarismaChartCellUpdate>(),
|
||||
Array.Empty<KarismaPositionUpdate>(),
|
||||
Array.Empty<KarismaCropKeyUpdate>(),
|
||||
payload.StyleColorUpdates,
|
||||
payload.VisibilityUpdates,
|
||||
CancellationToken.None)
|
||||
@@ -524,6 +525,10 @@ internal static class CutFileAudit
|
||||
string scenario,
|
||||
string frameLabel)
|
||||
{
|
||||
_ = ShowWindow(pgmWindow.Handle, ShowWindowRestore);
|
||||
_ = SetForegroundWindow(pgmWindow.Handle);
|
||||
Thread.Sleep(80);
|
||||
|
||||
var fileName = $"{result.Index:000}_{SanitizeFileName(result.FolderName)}_{SanitizeFileName(result.BaseName)}_{scenario.ToLowerInvariant()}_{frameLabel}.png";
|
||||
var outputPath = Path.Combine(options.CapturePath, fileName);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
@@ -961,6 +966,12 @@ internal static class CutFileAudit
|
||||
{
|
||||
var normalizedBaseName = NormalizeVariantName(result.BaseName);
|
||||
var explicitBaseName = TryResolveExplicitRgbSpec(result.FolderName, normalizedBaseName);
|
||||
if (explicitBaseName is not null && string.IsNullOrWhiteSpace(explicitBaseName))
|
||||
{
|
||||
mappingKind = "explicit-none";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(explicitBaseName) &&
|
||||
rgbCatalog.TryGetValue(BuildRgbCatalogKey(result.FolderName, explicitBaseName), out var explicitSpec))
|
||||
{
|
||||
@@ -1027,11 +1038,12 @@ internal static class CutFileAudit
|
||||
Add("Elect2026_Normal_민방", "모든후보_교육감", "모든후보_교육감");
|
||||
Add("Elect2026_Normal_민방", "사전_역대당선", "사전_역대당선자", "사전_역대당선자_기초단체장");
|
||||
Add("Elect2026_Normal_민방", "사전_역대당선_교육감", "사전_역대당선자_교육감");
|
||||
Add("Elect2026_Normal_민방", "이시각1위_광역단체장", "이시각1위_광역단체장");
|
||||
Add("Elect2026_Normal_민방", "이시각1위_광역단체장_5760", "이시각1위_광역단체장_5760");
|
||||
Add("Elect2026_Normal_민방", "이시각1위_기초단체장(5760동일)", "이시각1위_기초단체장");
|
||||
Add("Elect2026_Normal_민방", string.Empty, "사전_역대투표율");
|
||||
Add("Elect2026_Normal_민방", "이시각1위_광역단체장", "이시각1위_광역단체장", "이시각1위_광역단체장_HD");
|
||||
Add("Elect2026_Normal_민방", "이시각1위_광역단체장_5760", "이시각1위_광역단체장_5760", "이시각1위_광역단체장_L");
|
||||
Add("Elect2026_Normal_민방", "이시각1위_기초단체장(5760동일)", "이시각1위_기초단체장", "이시각1위_기초단체장_HD", "이시각1위_기초단체장_L");
|
||||
Add("Elect2026_Normal_민방", "접전,초접전", "접전_광역단체장", "접전_기초단체장", "초접전_광역단체장", "초접전_기초단체장");
|
||||
Add("Elect2026_Normal_민방", "판세_광역단체장", "판세_광역단체장", "판세_기초단체장");
|
||||
Add("Elect2026_Normal_민방", "판세_광역단체장", "판세_광역단체장", "판세_기초단체장", "역대시도판세_광역단체장", "역대시도판세_기초단체장");
|
||||
Add("Elect2026_Bottom_민방", "1-2위, 1-3위, 이시각1위", "1-2위_광역단체장", "1-2위_기초단체장", "1-3위_광역단체장", "1-3위_기초단체장", "1위_광역단체장", "1위_기초단체장");
|
||||
Add("Elect2026_Bottom_민방", "당선", "당선_광역단체장", "당선_광역의원", "당선_기초단체장", "당선_기초의원");
|
||||
Add("Elect2026_Bottom_민방", "모든후보", "전후보_광역단체장", "전후보_기초단체장");
|
||||
@@ -1296,29 +1308,62 @@ internal static class CutFileAudit
|
||||
|
||||
private static PgmWindow? TryFindPgmWindow()
|
||||
{
|
||||
var process = Process.GetProcessesByName("Tornado3")
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal));
|
||||
if (process is null || process.MainWindowHandle == IntPtr.Zero)
|
||||
var handle = IntPtr.Zero;
|
||||
var tornadoProcessIds = Process.GetProcessesByName("Tornado3")
|
||||
.Select(process => process.Id)
|
||||
.ToHashSet();
|
||||
|
||||
EnumWindows((candidateHandle, lParam) =>
|
||||
{
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_ = GetWindowThreadProcessId(candidateHandle, out var processId);
|
||||
if (!tornadoProcessIds.Contains((int)processId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var titleLength = GetWindowTextLength(candidateHandle);
|
||||
if (titleLength <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var title = new StringBuilder(titleLength + 1);
|
||||
_ = GetWindowText(candidateHandle, title, title.Capacity);
|
||||
if (string.Equals(title.ToString(), "PGM", StringComparison.Ordinal))
|
||||
{
|
||||
handle = candidateHandle;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TryGetDwmExtendedFrameBounds(process.MainWindowHandle, out var dwmBounds))
|
||||
if (TryGetDwmExtendedFrameBounds(handle, out var dwmBounds))
|
||||
{
|
||||
return new PgmWindow(process.MainWindowHandle, dwmBounds);
|
||||
return new PgmWindow(handle, dwmBounds);
|
||||
}
|
||||
|
||||
if (TryGetClientBounds(process.MainWindowHandle, out var clientBounds))
|
||||
if (TryGetClientBounds(handle, out var clientBounds))
|
||||
{
|
||||
return new PgmWindow(process.MainWindowHandle, clientBounds);
|
||||
return new PgmWindow(handle, clientBounds);
|
||||
}
|
||||
|
||||
if (!GetWindowRect(process.MainWindowHandle, out var rect))
|
||||
if (!GetWindowRect(handle, out var rect))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PgmWindow(process.MainWindowHandle, new Rect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
|
||||
return new PgmWindow(handle, new Rect(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
|
||||
}
|
||||
|
||||
private static bool TryGetDwmExtendedFrameBounds(IntPtr handle, out Rect bounds)
|
||||
@@ -1544,6 +1589,27 @@ internal static class CutFileAudit
|
||||
return value.Replace("|", "\\|", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetWindowTextLength(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect);
|
||||
@@ -1560,6 +1626,8 @@ internal static class CutFileAudit
|
||||
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, out NativeRect pvAttribute, int cbAttribute);
|
||||
|
||||
private const int DwmwaExtendedFrameBounds = 9;
|
||||
private const int ShowWindowRestore = 9;
|
||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
|
||||
private readonly record struct AuditScene(string ScenePath, string RelativePath, string FolderName, string BaseName, BroadcastChannel Channel);
|
||||
private readonly record struct AuditChannelBinding(int OutputChannelIndex, int LayerNo);
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\ITornado3Adapter.cs" Link="AppSource\Services\ITornado3Adapter.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaChartCellUpdate.cs" Link="AppSource\Services\KarismaChartCellUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCounterNumberKeyUpdate.cs" Link="AppSource\Services\KarismaCounterNumberKeyUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaCropKeyUpdate.cs" Link="AppSource\Services\KarismaCropKeyUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaEventHandler.cs" Link="AppSource\Services\KarismaEventHandler.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaPositionUpdate.cs" Link="AppSource\Services\KarismaPositionUpdate.cs" />
|
||||
<Compile Include="..\..\Tornado3_2026Election\Services\KarismaSceneResolutionReader.cs" Link="AppSource\Services\KarismaSceneResolutionReader.cs" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using KAsyncEngineLib;
|
||||
@@ -319,6 +321,12 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && string.Equals(args[0], "--audit-party-colors-live", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.ExitCode = await LiveCutValidation.RunPartyColorAuditAsync(args[1..]).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.Length > 0 && string.Equals(args[0], "--validate-current-api-cuts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Environment.ExitCode = await CurrentApiCutDiagnostics.RunAsync(args[1..]).ConfigureAwait(false);
|
||||
@@ -876,6 +884,14 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
var operationResult = ApplySceneOperation(handler, scene!, operation, options.Connection.Timeout);
|
||||
if (!string.Equals(operationResult.Result, eKResult.RESULT_SUCCESS.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
if (operation.ContinueOnFailure)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[SAVE-IMAGE] Optional operation {operationResult.Method} failed for '{operationResult.ObjectName}': " +
|
||||
$"{operationResult.Result} {operationResult.Detail}");
|
||||
continue;
|
||||
}
|
||||
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(
|
||||
true,
|
||||
@@ -1001,31 +1017,38 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
}
|
||||
}
|
||||
|
||||
var positionKeyUpdates = new List<PositionKeyUpdate>();
|
||||
if (options.PositionKey is not null)
|
||||
{
|
||||
positionKeyUpdates.Add(options.PositionKey);
|
||||
}
|
||||
|
||||
positionKeyUpdates.AddRange(options.PositionKeys);
|
||||
foreach (var positionKeyUpdate in positionKeyUpdates)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[SAVE-IMAGE] Setting position key object={options.PositionKey.ObjectName} index={options.PositionKey.KeyIndex} " +
|
||||
$"value=({options.PositionKey.X},{options.PositionKey.Y},{options.PositionKey.Z}) vector={options.PositionKey.VectorType}...");
|
||||
var sceneObject = scene.GetObject(options.PositionKey.ObjectName);
|
||||
$"[SAVE-IMAGE] Setting position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
|
||||
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
|
||||
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{options.PositionKey.ObjectName}' was not found."));
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
handler.ResetPositionKeyTask();
|
||||
sceneObject.SetPositionKey(
|
||||
options.PositionKey.KeyIndex,
|
||||
options.PositionKey.X,
|
||||
options.PositionKey.Y,
|
||||
options.PositionKey.Z,
|
||||
options.PositionKey.VectorType);
|
||||
positionKeyUpdate.KeyIndex,
|
||||
positionKeyUpdate.X,
|
||||
positionKeyUpdate.Y,
|
||||
positionKeyUpdate.Z,
|
||||
positionKeyUpdate.VectorType);
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{options.PositionKey.ObjectName}'." ));
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1033,7 +1056,7 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
if (positionKeyResult != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={options.PositionKey.ObjectName}"));
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1179,53 +1202,161 @@ static Task<SaveSceneImageProbeResult> SaveSceneImageAsync(SaveSceneImageOptions
|
||||
}
|
||||
}
|
||||
|
||||
var outputDirectory = Path.GetDirectoryName(options.OutputPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
foreach (var positionUpdate in options.PostPositions)
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
if (File.Exists(options.OutputPath))
|
||||
{
|
||||
File.Delete(options.OutputPath);
|
||||
}
|
||||
|
||||
Console.WriteLine("[SAVE-IMAGE] Calling SaveSceneImage()...");
|
||||
handler.ResetSaveSceneImageTask();
|
||||
scene.SaveSceneImage(options.OutputPath, options.Width, options.Height, options.Frame);
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.SaveSceneImageTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, "OnSaveSceneImage timed out."));
|
||||
return;
|
||||
}
|
||||
|
||||
var saveResult = handler.SaveSceneImageTask.Result;
|
||||
if (saveResult != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), options.OutputPath, $"OnSaveSceneImage result={saveResult}"));
|
||||
return;
|
||||
}
|
||||
|
||||
var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout;
|
||||
while (DateTime.UtcNow < fileWaitDeadline)
|
||||
{
|
||||
if (File.Exists(options.OutputPath))
|
||||
Console.WriteLine(
|
||||
$"[SAVE-IMAGE] Setting post-chart position object={positionUpdate.ObjectName} " +
|
||||
$"value=({positionUpdate.X},{positionUpdate.Y},{positionUpdate.Z}) vector={positionUpdate.VectorType}...");
|
||||
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
var info = new FileInfo(options.OutputPath);
|
||||
if (info.Length > 0)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", options.OutputPath, $"Saved {info.Length} bytes."));
|
||||
return;
|
||||
}
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionUpdate.ObjectName}' was not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
Thread.Sleep(50);
|
||||
handler.ResetPositionTask();
|
||||
sceneObject.SetPosition(
|
||||
positionUpdate.X,
|
||||
positionUpdate.Y,
|
||||
positionUpdate.Z,
|
||||
positionUpdate.VectorType);
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.PositionTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPosition timed out for '{positionUpdate.ObjectName}'." ));
|
||||
return;
|
||||
}
|
||||
|
||||
var positionResult = handler.PositionTask.Result;
|
||||
if (positionResult != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", positionResult.ToString(), options.OutputPath, $"OnSetPosition result={positionResult} object={positionUpdate.ObjectName}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, "Image file was not created."));
|
||||
foreach (var positionKeyUpdate in options.PostPositionKeys)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[SAVE-IMAGE] Setting post-chart position key object={positionKeyUpdate.ObjectName} index={positionKeyUpdate.KeyIndex} " +
|
||||
$"value=({positionKeyUpdate.X},{positionKeyUpdate.Y},{positionKeyUpdate.Z}) vector={positionKeyUpdate.VectorType}...");
|
||||
var sceneObject = scene.GetObject(positionKeyUpdate.ObjectName);
|
||||
if (sceneObject is null)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", options.OutputPath, $"Object '{positionKeyUpdate.ObjectName}' was not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
handler.ResetPositionKeyTask();
|
||||
sceneObject.SetPositionKey(
|
||||
positionKeyUpdate.KeyIndex,
|
||||
positionKeyUpdate.X,
|
||||
positionKeyUpdate.Y,
|
||||
positionKeyUpdate.Z,
|
||||
positionKeyUpdate.VectorType);
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.PositionKeyTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", options.OutputPath, $"OnSetPositionKey timed out for '{positionKeyUpdate.ObjectName}'." ));
|
||||
return;
|
||||
}
|
||||
|
||||
var positionKeyResult = handler.PositionKeyTask.Result;
|
||||
if (positionKeyResult != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", positionKeyResult.ToString(), options.OutputPath, $"OnSetPositionKey result={positionKeyResult} object={positionKeyUpdate.ObjectName}"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var captures = new List<(string OutputPath, int Frame)>();
|
||||
if (options.Frames.Count > 0)
|
||||
{
|
||||
var captureDirectory = options.OutputDirectory ?? options.OutputPath;
|
||||
foreach (var captureFrame in options.Frames)
|
||||
{
|
||||
captures.Add((
|
||||
Path.GetFullPath(Path.Combine(
|
||||
captureDirectory,
|
||||
string.Format(CultureInfo.InvariantCulture, options.OutputPattern, captureFrame))),
|
||||
captureFrame));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
captures.Add((options.OutputPath, options.Frame));
|
||||
}
|
||||
|
||||
long totalBytes = 0;
|
||||
foreach (var capture in captures)
|
||||
{
|
||||
var outputDirectory = Path.GetDirectoryName(capture.OutputPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
if (File.Exists(capture.OutputPath))
|
||||
{
|
||||
File.Delete(capture.OutputPath);
|
||||
}
|
||||
|
||||
Console.WriteLine($"[SAVE-IMAGE] Calling SaveSceneImage() frame={capture.Frame} output={capture.OutputPath}...");
|
||||
handler.ResetSaveSceneImageTask();
|
||||
scene.SaveSceneImage(capture.OutputPath, options.Width, options.Height, capture.Frame);
|
||||
|
||||
if (!WaitForTaskWithMessagePump(handler.SaveSceneImageTask, options.Connection.Timeout))
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "TIMEOUT", capture.OutputPath, "OnSaveSceneImage timed out."));
|
||||
return;
|
||||
}
|
||||
|
||||
var saveResult = handler.SaveSceneImageTask.Result;
|
||||
if (saveResult != eKResult.RESULT_SUCCESS)
|
||||
{
|
||||
completion.TrySetResult(
|
||||
new SaveSceneImageProbeResult(true, "SUCCESS", saveResult.ToString(), capture.OutputPath, $"OnSaveSceneImage result={saveResult}"));
|
||||
return;
|
||||
}
|
||||
|
||||
var savedThisFrame = false;
|
||||
var fileWaitDeadline = DateTime.UtcNow + options.Connection.Timeout;
|
||||
while (DateTime.UtcNow < fileWaitDeadline)
|
||||
{
|
||||
if (File.Exists(capture.OutputPath))
|
||||
{
|
||||
var info = new FileInfo(capture.OutputPath);
|
||||
if (info.Length > 0)
|
||||
{
|
||||
totalBytes += info.Length;
|
||||
savedThisFrame = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Thread.Sleep(50);
|
||||
}
|
||||
|
||||
if (!savedThisFrame)
|
||||
{
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "FAILED", capture.OutputPath, "Image file was not created."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var resultOutput = options.Frames.Count > 0
|
||||
? options.OutputDirectory ?? options.OutputPath
|
||||
: options.OutputPath;
|
||||
var detail = captures.Count == 1
|
||||
? $"Saved {totalBytes} bytes."
|
||||
: $"Saved {captures.Count} frames ({totalBytes} bytes).";
|
||||
completion.TrySetResult(new SaveSceneImageProbeResult(true, "SUCCESS", "SUCCESS", resultOutput, detail));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -3430,6 +3561,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
int Width,
|
||||
int Height,
|
||||
int Frame,
|
||||
IReadOnlyList<int> Frames,
|
||||
string? OutputDirectory,
|
||||
string OutputPattern,
|
||||
string? SetObjectName,
|
||||
string? SetObjectValue,
|
||||
string? VisibleObjectName,
|
||||
@@ -3442,6 +3576,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
PositionUpdate? Position,
|
||||
IReadOnlyList<PositionUpdate> Positions,
|
||||
PositionKeyUpdate? PositionKey,
|
||||
IReadOnlyList<PositionKeyUpdate> PositionKeys,
|
||||
IReadOnlyList<PositionUpdate> PostPositions,
|
||||
IReadOnlyList<PositionKeyUpdate> PostPositionKeys,
|
||||
string? ChartObjectName,
|
||||
string? ChartCsvPath,
|
||||
IReadOnlyList<ChartCellUpdate> ChartCells,
|
||||
@@ -3455,6 +3592,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
string? scenePath = null;
|
||||
string? sceneAlias = null;
|
||||
string? outputPath = null;
|
||||
string? outputDirectory = null;
|
||||
string outputPattern = "frame_{0:D4}.png";
|
||||
IReadOnlyList<int> frames = Array.Empty<int>();
|
||||
string? setObjectName = null;
|
||||
string? setObjectValue = null;
|
||||
string? visibleObjectName = null;
|
||||
@@ -3474,6 +3614,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
string? positionKeyObjectName = null;
|
||||
int positionKeyIndex = 1;
|
||||
string? positionKeyRaw = null;
|
||||
string? positionKeysRaw = null;
|
||||
string? postPositionsRaw = null;
|
||||
string? postPositionKeysRaw = null;
|
||||
string? chartObjectName = null;
|
||||
string? chartCsvPath = null;
|
||||
string? chartCellsRaw = null;
|
||||
@@ -3497,6 +3640,12 @@ internal sealed record SaveSceneImageOptions(
|
||||
case "--output" when index + 1 < args.Length:
|
||||
outputPath = args[++index];
|
||||
break;
|
||||
case "--output-dir" when index + 1 < args.Length:
|
||||
outputDirectory = args[++index];
|
||||
break;
|
||||
case "--output-pattern" when index + 1 < args.Length:
|
||||
outputPattern = args[++index];
|
||||
break;
|
||||
case "--set-object" when index + 1 < args.Length:
|
||||
setObjectName = args[++index];
|
||||
break;
|
||||
@@ -3561,6 +3710,15 @@ internal sealed record SaveSceneImageOptions(
|
||||
case "--position-key" when index + 1 < args.Length:
|
||||
positionKeyRaw = args[++index];
|
||||
break;
|
||||
case "--position-keys" when index + 1 < args.Length:
|
||||
positionKeysRaw = args[++index];
|
||||
break;
|
||||
case "--post-positions" when index + 1 < args.Length:
|
||||
postPositionsRaw = args[++index];
|
||||
break;
|
||||
case "--post-position-keys" when index + 1 < args.Length:
|
||||
postPositionKeysRaw = args[++index];
|
||||
break;
|
||||
case "--chart-object" when index + 1 < args.Length:
|
||||
chartObjectName = args[++index];
|
||||
break;
|
||||
@@ -3591,6 +3749,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
frame = parsedFrame;
|
||||
index++;
|
||||
break;
|
||||
case "--frames" when index + 1 < args.Length:
|
||||
frames = ParseFrameSequence(args[++index]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3599,7 +3760,18 @@ internal sealed record SaveSceneImageOptions(
|
||||
throw new ArgumentException("--scene is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
if (frames.Count > 0)
|
||||
{
|
||||
outputDirectory ??= outputPath;
|
||||
if (string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
throw new ArgumentException("--output-dir is required when --frames is provided.");
|
||||
}
|
||||
|
||||
outputDirectory = Path.GetFullPath(outputDirectory);
|
||||
outputPath ??= outputDirectory;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
throw new ArgumentException("--output is required.");
|
||||
}
|
||||
@@ -3618,6 +3790,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
width,
|
||||
height,
|
||||
frame,
|
||||
frames,
|
||||
outputDirectory,
|
||||
outputPattern,
|
||||
setObjectName,
|
||||
setObjectValue,
|
||||
visibleObjectName,
|
||||
@@ -3630,6 +3805,9 @@ internal sealed record SaveSceneImageOptions(
|
||||
ParsePosition(positionObjectName, positionRaw),
|
||||
ParsePositions(positionsRaw),
|
||||
ParsePositionKey(positionKeyObjectName, positionKeyIndex, positionKeyRaw),
|
||||
ParsePositionKeys(positionKeysRaw),
|
||||
ParsePositions(postPositionsRaw),
|
||||
ParsePositionKeys(postPositionKeysRaw),
|
||||
chartObjectName,
|
||||
chartCsvPath,
|
||||
ParseChartCells(chartCellsRaw),
|
||||
@@ -3638,6 +3816,53 @@ internal sealed record SaveSceneImageOptions(
|
||||
ParsePathModifications(modifyPathRaw));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<int> ParseFrameSequence(string value)
|
||||
{
|
||||
var frames = new List<int>();
|
||||
foreach (var token in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var rangeMatch = Regex.Match(token, @"^(?<start>-?\d+)-(?<end>-?\d+)(?::(?<step>\d+))?$", RegexOptions.CultureInvariant);
|
||||
if (rangeMatch.Success)
|
||||
{
|
||||
var start = int.Parse(rangeMatch.Groups["start"].Value, CultureInfo.InvariantCulture);
|
||||
var end = int.Parse(rangeMatch.Groups["end"].Value, CultureInfo.InvariantCulture);
|
||||
var step = rangeMatch.Groups["step"].Success
|
||||
? int.Parse(rangeMatch.Groups["step"].Value, CultureInfo.InvariantCulture)
|
||||
: 1;
|
||||
if (step <= 0)
|
||||
{
|
||||
throw new ArgumentException("--frames range step must be greater than zero.");
|
||||
}
|
||||
|
||||
if (start <= end)
|
||||
{
|
||||
for (var frame = start; frame <= end; frame += step)
|
||||
{
|
||||
frames.Add(frame);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var frame = start; frame >= end; frame -= step)
|
||||
{
|
||||
frames.Add(frame);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var singleFrame))
|
||||
{
|
||||
throw new ArgumentException($"Invalid frame token: {token}");
|
||||
}
|
||||
|
||||
frames.Add(singleFrame);
|
||||
}
|
||||
|
||||
return frames.Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static CloneObjectUpdate? ParseCloneObject(string? sourceObjectName, string? variableName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceObjectName) || string.IsNullOrWhiteSpace(variableName))
|
||||
@@ -3776,6 +4001,38 @@ internal sealed record SaveSceneImageOptions(
|
||||
return new PositionKeyUpdate(objectName, keyIndex, x, y, z, vectorType);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PositionKeyUpdate> ParsePositionKeys(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return Array.Empty<PositionKeyUpdate>();
|
||||
}
|
||||
|
||||
var updates = new List<PositionKeyUpdate>();
|
||||
foreach (var token in raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var nameParts = token.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||
if (nameParts.Length != 2)
|
||||
{
|
||||
throw new ArgumentException($"Invalid position key update: {token}");
|
||||
}
|
||||
|
||||
var objectAndKey = nameParts[0].Split('#', 2, StringSplitOptions.TrimEntries);
|
||||
if (objectAndKey.Length != 2 || !int.TryParse(objectAndKey[1], out var keyIndex))
|
||||
{
|
||||
throw new ArgumentException($"Invalid position key object/index: {nameParts[0]}");
|
||||
}
|
||||
|
||||
var update = ParsePositionKey(objectAndKey[0], keyIndex, nameParts[1]);
|
||||
if (update is not null)
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ChartCellUpdate> ParseChartCells(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
@@ -4410,6 +4667,8 @@ internal sealed class SceneValidationOperation
|
||||
public int A { get; set; } = 255;
|
||||
|
||||
public bool Visible { get; set; }
|
||||
|
||||
public bool ContinueOnFailure { get; set; }
|
||||
}
|
||||
|
||||
internal sealed record SceneOperationValidationResult(string ObjectName, string Method, string Payload, string Result, string Detail);
|
||||
|
||||
Reference in New Issue
Block a user