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