Files
Tornado3_2026Election/Tornado3_2026Election/Services/ChannelScheduleEngine.cs

1691 lines
60 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Tornado3_2026Election.Common;
using Tornado3_2026Election.Domain;
namespace Tornado3_2026Election.Services;
public sealed class ChannelScheduleEngine
{
private const int PreviewFrame = -1;
private static readonly TimeSpan MinimumNextPreviewWindow = TimeSpan.FromSeconds(2.5);
private readonly ITornado3Adapter _adapter;
private readonly IDataRefreshGate _dataRefreshGate;
private readonly Func<BroadcastStationProfile> _stationProvider;
private readonly Func<string> _imageRootProvider;
private readonly Func<VideoWallLayoutPreset> _videoWallLayoutPresetProvider;
private readonly Func<string, FormatTemplateDefinition?> _templateResolver;
private readonly LogService _logService;
private readonly SemaphoreSlim _executionLock = new(1, 1);
private CancellationTokenSource? _playbackCts;
private TaskCompletionSource<bool>? _advanceSignal;
private Guid? _lastPlaybackItemId;
private Guid? _skipCurrentItemId;
private ChannelScheduleItem? _directPlaybackItem;
private PreparedCutFrame? _preparedCutFrame;
public ChannelScheduleEngine(
BroadcastChannel channel,
ObservableCollection<ChannelScheduleItem> queue,
ITornado3Adapter adapter,
IDataRefreshGate dataRefreshGate,
Func<BroadcastStationProfile> stationProvider,
Func<string> imageRootProvider,
Func<VideoWallLayoutPreset> videoWallLayoutPresetProvider,
Func<string, FormatTemplateDefinition?> templateResolver,
LogService logService)
{
Channel = channel;
Queue = queue;
_adapter = adapter;
_dataRefreshGate = dataRefreshGate;
_stationProvider = stationProvider;
_imageRootProvider = imageRootProvider;
_videoWallLayoutPresetProvider = videoWallLayoutPresetProvider;
_templateResolver = templateResolver;
_logService = logService;
}
public BroadcastChannel Channel { get; }
public ObservableCollection<ChannelScheduleItem> Queue { get; }
public bool LoopEnabled { get; set; }
public EmptyScheduleBehavior EmptyScheduleBehavior { get; set; } = EmptyScheduleBehavior.ImmediateOut;
public bool IsRunning { get; private set; }
public ChannelScheduleItem? ActivePlaybackItem =>
_directPlaybackItem?.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending
? _directPlaybackItem
: Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
public event EventHandler? QueueChanged;
public bool IsPreparedItem(ChannelScheduleItem item)
{
return _preparedCutFrame?.Item.Id == item.Id;
}
public async Task StartAsync()
{
if (IsRunning)
{
await AdvanceToNextAsync().ConfigureAwait(false);
return;
}
if (_preparedCutFrame is { Item: var preparedItem } && !Queue.Contains(preparedItem))
{
ClearPreparedFrame(resetState: true);
}
_lastPlaybackItemId = null;
_playbackCts = new CancellationTokenSource();
IsRunning = true;
RefreshQueueMarkers();
_ = RunAsync(_playbackCts.Token);
await Task.CompletedTask;
}
public async Task StopAsync(bool takeOutputOff = true)
{
if (!IsRunning)
{
if (_preparedCutFrame is null)
{
return;
}
if (takeOutputOff)
{
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
}
ClearPreparedFrame(resetState: true);
_lastPlaybackItemId = null;
_skipCurrentItemId = null;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
return;
}
_playbackCts?.Cancel();
_advanceSignal?.TrySetResult(true);
if (takeOutputOff)
{
await _adapter.OutAsync(Channel, CancellationToken.None).ConfigureAwait(false);
}
foreach (var item in Queue.Where(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending))
{
item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
}
_lastPlaybackItemId = null;
_skipCurrentItemId = null;
ClearPreparedFrame(resetState: false);
IsRunning = false;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
public async Task PrepareNextAsync(CancellationToken cancellationToken)
{
if (IsRunning)
{
return;
}
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_lastPlaybackItemId = null;
ClearPreparedFrame(resetState: true);
RefreshQueueMarkers();
var next = GetNextPlayableItem();
if (next is null)
{
_logService.Warning($"[{Channel}] 준비할 스케줄 컷이 없습니다.");
return;
}
var template = _templateResolver(next.FormatId);
if (template is null)
{
next.State = ScheduleQueueItemState.Error;
next.LastError = "포맷을 찾을 수 없습니다.";
_logService.Error($"[{Channel}] Missing template: {next.FormatId}");
RefreshQueueMarkers();
return;
}
await PrepareFirstCutAsync(next, template, cancellationToken).ConfigureAwait(false);
}
finally
{
_executionLock.Release();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
}
public async Task PrepareDirectAsync(
ChannelScheduleItem item,
FormatTemplateDefinition template,
CancellationToken cancellationToken)
{
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
ClearPreparedFrame(resetState: true);
_directPlaybackItem = item;
QueueChanged?.Invoke(this, EventArgs.Empty);
await PrepareFirstCutAsync(item, template, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (_directPlaybackItem == item)
{
_directPlaybackItem = null;
}
item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty;
throw;
}
finally
{
_executionLock.Release();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
}
public void ClearDirectPlayback()
{
ClearPreparedFrame(resetState: true);
if (_directPlaybackItem is not null)
{
_directPlaybackItem.State = ScheduleQueueItemState.Queued;
_directPlaybackItem.CurrentRegionLabel = string.Empty;
_directPlaybackItem.ClearRenderedPreview();
_directPlaybackItem.ClearInternalNextPreview();
_directPlaybackItem = null;
}
QueueChanged?.Invoke(this, EventArgs.Empty);
}
public async Task PlayDirectAsync(
ChannelScheduleItem item,
FormatTemplateDefinition template,
CancellationToken cancellationToken)
{
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_directPlaybackItem = item;
QueueChanged?.Invoke(this, EventArgs.Empty);
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
await PlayItemAsync(item, template, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
item.State = ScheduleQueueItemState.Queued;
item.CurrentRegionLabel = string.Empty;
}
finally
{
_directPlaybackItem = null;
_executionLock.Release();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
}
public void Reset()
{
_lastPlaybackItemId = null;
ClearPreparedFrame(resetState: false);
foreach (var item in Queue)
{
item.State = ScheduleQueueItemState.Queued;
item.LastError = string.Empty;
item.CurrentRegionLabel = string.Empty;
item.ClearRenderedPreview();
item.ClearInternalNextPreview();
}
RefreshQueueMarkers();
}
public async Task ForceNextAsync()
{
await AdvanceToNextAsync().ConfigureAwait(false);
}
public async Task ForceQueueNextAsync()
{
await ForceNextAsync().ConfigureAwait(false);
}
public Task AdvanceToNextAsync()
{
if (!IsRunning)
{
return Task.CompletedTask;
}
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
if (activeItem is not null)
{
_skipCurrentItemId = activeItem.Id;
_lastPlaybackItemId = activeItem.Id;
activeItem.State = ScheduleQueueItemState.Queued;
activeItem.LastError = string.Empty;
activeItem.CurrentRegionLabel = string.Empty;
activeItem.ClearInternalNextPreview();
}
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
_advanceSignal?.TrySetResult(true);
return Task.CompletedTask;
}
public bool Remove(ChannelScheduleItem? item)
{
if (item is null || !item.CanDelete)
{
return false;
}
var removed = Queue.Remove(item);
if (removed)
{
if (_lastPlaybackItemId == item.Id)
{
_lastPlaybackItemId = null;
}
RefreshQueueMarkers();
}
return removed;
}
public void Enqueue(ChannelScheduleItem item)
{
item.State = ScheduleQueueItemState.Queued;
Queue.Add(item);
RefreshQueueMarkers();
}
public bool MoveUp(ChannelScheduleItem? item) => Move(item, -1);
public bool MoveDown(ChannelScheduleItem? item) => Move(item, 1);
public bool PromoteToNext(ChannelScheduleItem? item)
{
if (item is null)
{
return false;
}
var anchorItem = ActivePlaybackItem ?? GetLastPlaybackItem();
var targetIndex = anchorItem is null ? 0 : Queue.IndexOf(anchorItem) + 1;
var currentIndex = Queue.IndexOf(item);
if (currentIndex < 0 || targetIndex < 0 || targetIndex > Queue.Count || currentIndex == targetIndex)
{
return false;
}
if (currentIndex < targetIndex)
{
targetIndex--;
}
Queue.Move(currentIndex, targetIndex);
RefreshQueueMarkers();
return true;
}
private bool Move(ChannelScheduleItem? item, int delta)
{
if (item is null)
{
return false;
}
var currentIndex = Queue.IndexOf(item);
var targetIndex = currentIndex + delta;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= Queue.Count)
{
return false;
}
Queue.Move(currentIndex, targetIndex);
RefreshQueueMarkers();
return true;
}
private async Task RunAsync(CancellationToken cancellationToken)
{
await _executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
while (!cancellationToken.IsCancellationRequested)
{
var next = GetNextPlayableItem();
if (next is null)
{
if (EmptyScheduleBehavior == EmptyScheduleBehavior.ImmediateOut)
{
await _adapter.OutAsync(Channel, cancellationToken).ConfigureAwait(false);
}
IsRunning = false;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
return;
}
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
var template = _templateResolver(next.FormatId);
if (template is null)
{
next.State = ScheduleQueueItemState.Error;
next.LastError = "포맷을 찾을 수 없습니다.";
_lastPlaybackItemId = next.Id;
_logService.Error($"[{Channel}] Missing template: {next.FormatId}");
RefreshQueueMarkers();
continue;
}
await PlayItemAsync(next, template, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty);
}
}
catch (OperationCanceledException)
{
IsRunning = false;
RefreshQueueMarkers();
}
catch (Exception ex)
{
IsRunning = false;
var sendingItem = Queue.FirstOrDefault(item => item.State == ScheduleQueueItemState.Sending);
if (sendingItem is not null)
{
sendingItem.State = ScheduleQueueItemState.Error;
sendingItem.LastError = ex.Message;
sendingItem.CurrentRegionLabel = string.Empty;
sendingItem.ClearInternalNextPreview();
ClearSkipCurrentItem(sendingItem);
}
_logService.Error($"[{Channel}] Schedule playback stopped: {ex.Message}");
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
}
finally
{
_executionLock.Release();
}
}
private async Task PrepareFirstCutAsync(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
CancellationToken cancellationToken)
{
queueItem.State = ScheduleQueueItemState.Sending;
queueItem.LastError = string.Empty;
queueItem.ClearInternalNextPreview();
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
var station = _stationProvider();
var imageRootPath = _imageRootProvider();
CutPreviewFrame? previewFrame;
try
{
previewFrame = await TryBuildPreviewFrameAsync(queueItem, template, station, imageRootPath, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = ex.Message;
queueItem.CurrentRegionLabel = string.Empty;
RefreshQueueMarkers();
_logService.Warning($"[{Channel}] 준비 컷 데이터 구성 실패: {queueItem.DisplayName} / {ex.Message}");
return;
}
if (previewFrame is null)
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
queueItem.CurrentRegionLabel = string.Empty;
RefreshQueueMarkers();
_logService.Warning($"[{Channel}] 준비할 수 있는 컷 데이터가 없습니다: {queueItem.DisplayName}");
return;
}
queueItem.CurrentRegionLabel = previewFrame.RegionLabel;
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _adapter
.ApplyCutAsync(Channel, template, previewFrame.Cut, previewFrame.Snapshot, station, imageRootPath, cancellationToken)
.ConfigureAwait(false);
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
_preparedCutFrame = new PreparedCutFrame(
queueItem,
template,
previewFrame.Cut,
previewFrame.Snapshot,
station,
imageRootPath,
previewFrame.RegionLabel);
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
RefreshQueueMarkers();
}
private async Task PlayItemAsync(ChannelScheduleItem queueItem, FormatTemplateDefinition template, CancellationToken cancellationToken)
{
var station = _stationProvider();
var imageRootPath = _imageRootProvider();
var resolvedCuts = ResolveCuts(template, station);
var hasEndScene = KarismaSceneResolver.HasEndScene(template, imageRootPath);
var regionTargets = await _dataRefreshGate
.ResolveScheduleRegionTargetsAsync(queueItem, template, station, cancellationToken)
.ConfigureAwait(false);
if (regionTargets.Count == 0)
{
queueItem.State = ScheduleQueueItemState.Error;
queueItem.LastError = "송출 가능한 지역 데이터가 없습니다.";
queueItem.CurrentRegionLabel = string.Empty;
_lastPlaybackItemId = queueItem.Id;
RefreshQueueMarkers();
return;
}
var playedAny = false;
var lastFailure = string.Empty;
if (ShouldUseAggregateScheduleSnapshot(template))
{
var aggregateRegionGroups = ResolveAggregateScheduleRegionGroups(template, regionTargets);
for (var groupIndex = 0; groupIndex < aggregateRegionGroups.Count; groupIndex++)
{
var aggregateRegionGroup = aggregateRegionGroups[groupIndex];
ElectionDataSnapshot aggregateSnapshot;
try
{
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
aggregateSnapshot = await _dataRefreshGate
.GetAggregateScheduleSnapshotAsync(queueItem, template, station, aggregateRegionGroup, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {ex.Message}";
_logService.Warning($"[{Channel}] 집계형 송출 데이터 수신 실패: {lastFailure}");
continue;
}
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, aggregateSnapshot, out var aggregateValidationError))
{
lastFailure = $"{ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup)}: {aggregateValidationError}";
_logService.Warning($"[{Channel}] 집계형 송출 데이터 검증 실패: {lastFailure}");
continue;
}
queueItem.CurrentRegionLabel = ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup);
var isLastGroup = groupIndex == aggregateRegionGroups.Count - 1;
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, aggregateSnapshot, hasEndScene && isLastGroup);
for (var cutIndex = 0; cutIndex < playbackCuts.Count; cutIndex++)
{
var cut = playbackCuts[cutIndex];
Func<CancellationToken, Task<CutPreviewFrame?>>? nextPreviewFactory = null;
if (cutIndex + 1 < playbackCuts.Count)
{
var nextCut = playbackCuts[cutIndex + 1];
var nextRegionLabel = queueItem.CurrentRegionLabel;
nextPreviewFactory = _ => Task.FromResult<CutPreviewFrame?>(
new CutPreviewFrame(nextCut, aggregateSnapshot, nextRegionLabel));
}
else if (groupIndex + 1 < aggregateRegionGroups.Count)
{
var nextGroupIndex = groupIndex + 1;
nextPreviewFactory = token => TryBuildAggregatePreviewFrameAsync(
queueItem,
template,
station,
resolvedCuts,
aggregateRegionGroups,
nextGroupIndex,
hasEndScene,
token);
}
await PlayCutFrameAsync(queueItem, template, cut, aggregateSnapshot, station, imageRootPath, nextPreviewFactory, cancellationToken)
.ConfigureAwait(false);
if (ShouldSkipCurrentItem(queueItem))
{
break;
}
}
playedAny = true;
if (ShouldSkipCurrentItem(queueItem))
{
break;
}
}
queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview();
_lastPlaybackItemId = queueItem.Id;
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : ScheduleQueueItemState.Error;
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers();
return;
}
for (var regionIndex = 0; regionIndex < regionTargets.Count; regionIndex++)
{
var regionTarget = regionTargets[regionIndex];
ElectionDataSnapshot snapshot;
try
{
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
snapshot = await _dataRefreshGate
.GetScheduleSnapshotAsync(queueItem, template, regionTarget, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lastFailure = $"{regionTarget.DisplayName}: {ex.Message}";
_logService.Warning($"[{Channel}] 스케줄 지역 수신 실패: {lastFailure}");
continue;
}
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out var validationError))
{
lastFailure = $"{regionTarget.DisplayName}: {validationError}";
_logService.Warning($"[{Channel}] 스케줄 지역 검증 실패: {lastFailure}");
continue;
}
queueItem.CurrentRegionLabel = regionTarget.DisplayName;
var isLastRegion = regionIndex == regionTargets.Count - 1;
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastRegion);
queueItem.TotalCuts = playbackCuts.Count;
for (var cutIndex = 0; cutIndex < playbackCuts.Count; cutIndex++)
{
var cut = playbackCuts[cutIndex];
Func<CancellationToken, Task<CutPreviewFrame?>>? nextPreviewFactory = null;
if (cutIndex + 1 < playbackCuts.Count)
{
var nextCut = playbackCuts[cutIndex + 1];
var nextRegionLabel = regionTarget.DisplayName;
nextPreviewFactory = _ => Task.FromResult<CutPreviewFrame?>(
new CutPreviewFrame(nextCut, snapshot, nextRegionLabel));
}
else if (regionIndex + 1 < regionTargets.Count)
{
var nextRegionIndex = regionIndex + 1;
nextPreviewFactory = token => TryBuildRegionPreviewFrameAsync(
queueItem,
template,
station,
imageRootPath,
resolvedCuts,
regionTargets,
nextRegionIndex,
hasEndScene,
token);
}
await PlayCutFrameAsync(queueItem, template, cut, snapshot, station, imageRootPath, nextPreviewFactory, cancellationToken)
.ConfigureAwait(false);
if (ShouldSkipCurrentItem(queueItem))
{
break;
}
}
playedAny = true;
if (ShouldSkipCurrentItem(queueItem))
{
break;
}
}
queueItem.CurrentRegionLabel = string.Empty;
queueItem.ClearInternalNextPreview();
_lastPlaybackItemId = queueItem.Id;
queueItem.State = playedAny ? ScheduleQueueItemState.Queued : ScheduleQueueItemState.Error;
queueItem.LastError = playedAny ? string.Empty : (string.IsNullOrWhiteSpace(lastFailure) ? "송출 가능한 지역 데이터가 없습니다." : lastFailure);
ClearSkipCurrentItem(queueItem);
RefreshQueueMarkers();
}
private async Task PlayCutFrameAsync(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
BroadcastStationProfile station,
string imageRootPath,
Func<CancellationToken, Task<CutPreviewFrame?>>? nextInternalPreviewFrameFactory,
CancellationToken cancellationToken)
{
queueItem.State = ScheduleQueueItemState.Sending;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
var preparedFrame = TryConsumePreparedFrame(queueItem, template, cut);
var playbackCut = preparedFrame?.Cut ?? cut;
var playbackSnapshot = preparedFrame?.Snapshot ?? snapshot;
var playbackStation = preparedFrame?.Station ?? station;
var playbackImageRootPath = preparedFrame?.ImageRootPath ?? imageRootPath;
if (preparedFrame is not null)
{
queueItem.CurrentRegionLabel = preparedFrame.RegionLabel;
}
else
{
await _adapter.EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
await _adapter.ApplyCutAsync(Channel, template, cut, snapshot, station, imageRootPath, cancellationToken).ConfigureAwait(false);
await _adapter.PrepareAsync(Channel, cancellationToken).ConfigureAwait(false);
}
await _adapter.TakeAsync(Channel, cancellationToken).ConfigureAwait(false);
var onAirAt = DateTimeOffset.Now;
queueItem.State = ScheduleQueueItemState.OnAir;
queueItem.LastPlayedAt = onAirAt;
RefreshQueueMarkers();
QueueChanged?.Invoke(this, EventArgs.Empty);
var signal = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_advanceSignal = signal;
if (ShouldSkipCurrentItem(queueItem))
{
signal.TrySetResult(true);
}
var durationSeconds = ScheduleTemplatePolicy.NormalizeCutDurationSeconds(cut.DurationSeconds, template);
var playbackDuration = TimeSpan.FromSeconds(durationSeconds);
await CaptureCurrentPreviewAsync(queueItem, template, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty);
if (!ShouldSkipCurrentItem(queueItem))
{
var remainingForPreview = playbackDuration - (DateTimeOffset.Now - onAirAt);
if (remainingForPreview > MinimumNextPreviewWindow)
{
CutPreviewFrame? nextInternalPreviewFrame = null;
if (nextInternalPreviewFrameFactory is not null)
{
try
{
nextInternalPreviewFrame = await nextInternalPreviewFrameFactory(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logService.Warning($"[{Channel}] 다음 지역 프리뷰 데이터 준비 실패: {queueItem.DisplayName} / {ex.Message}");
}
}
await CaptureNextPreviewAsync(queueItem, template, nextInternalPreviewFrame, station, imageRootPath, cancellationToken).ConfigureAwait(false);
QueueChanged?.Invoke(this, EventArgs.Empty);
}
else
{
MarkQueueNextPreviewStatus(queueItem, $"빠른 송출 중 프리뷰 생략 {DateTimeOffset.Now:HH:mm:ss}");
}
}
if (ShouldSkipCurrentItem(queueItem))
{
return;
}
var remainingDuration = playbackDuration - (DateTimeOffset.Now - onAirAt);
if (remainingDuration <= TimeSpan.Zero)
{
return;
}
var delayTask = Task.Delay(remainingDuration, cancellationToken);
await Task.WhenAny(delayTask, signal.Task).ConfigureAwait(false);
}
private async Task CaptureCurrentPreviewAsync(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
CancellationToken cancellationToken)
{
if (!_adapter.IsLiveCg)
{
return;
}
var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider());
var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, queueItem.Id, "current");
var captured = await _adapter.TryCapturePendingCutPreviewAsync(
Channel,
previewPath,
size.Width,
size.Height,
PreviewFrame,
cancellationToken).ConfigureAwait(false);
if (!captured)
{
await UiDispatcher.EnqueueAsync(() =>
queueItem.UpdateRenderedPreviewStatus($"현재 화면 캡처 지연 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
return;
}
await UiDispatcher.EnqueueAsync(() =>
queueItem.UpdateRenderedPreview(previewPath, $"현재 변수 적용 캡처 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
}
private async Task CaptureNextPreviewAsync(
ChannelScheduleItem activeItem,
FormatTemplateDefinition activeTemplate,
CutPreviewFrame? internalNextPreviewFrame,
BroadcastStationProfile station,
string imageRootPath,
CancellationToken cancellationToken)
{
if (!_adapter.IsLiveCg)
{
return;
}
if (internalNextPreviewFrame is not null)
{
await CaptureInternalNextPreviewAsync(
activeItem,
activeTemplate,
internalNextPreviewFrame,
station,
imageRootPath,
cancellationToken).ConfigureAwait(false);
return;
}
activeItem.ClearInternalNextPreview();
var nextItem = GetPreviewNextItem(activeItem);
if (nextItem is null)
{
return;
}
var template = _templateResolver(nextItem.FormatId);
if (template is null)
{
return;
}
CutPreviewFrame? previewFrame;
try
{
previewFrame = await TryBuildPreviewFrameAsync(nextItem, template, station, imageRootPath, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logService.Warning($"[{Channel}] 다음 컷 프리뷰 데이터 준비 실패: {nextItem.DisplayName} / {ex.Message}");
return;
}
if (previewFrame is null)
{
return;
}
var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider());
var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, nextItem.Id, "next");
var captured = await _adapter.TryCaptureCutPreviewAsync(
Channel,
template,
previewFrame.Cut,
previewFrame.Snapshot,
station,
imageRootPath,
previewPath,
size.Width,
size.Height,
PreviewFrame,
cancellationToken).ConfigureAwait(false);
if (!captured)
{
await UiDispatcher.EnqueueAsync(() =>
nextItem.UpdateRenderedPreviewStatus($"다음 프리뷰 캡처 지연 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
return;
}
await UiDispatcher.EnqueueAsync(() =>
nextItem.UpdateRenderedPreview(previewPath, $"다음 변수 적용 캡처 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
}
private void MarkQueueNextPreviewStatus(ChannelScheduleItem activeItem, string statusLabel)
{
var nextItem = GetPreviewNextItem(activeItem);
if (nextItem is null)
{
return;
}
UiDispatcher.Enqueue(() => nextItem.UpdateRenderedPreviewStatus(statusLabel));
}
private async Task CaptureInternalNextPreviewAsync(
ChannelScheduleItem activeItem,
FormatTemplateDefinition template,
CutPreviewFrame previewFrame,
BroadcastStationProfile station,
string imageRootPath,
CancellationToken cancellationToken)
{
var size = ThumbnailLayoutResolver.ResolveGenerationSize(template, _videoWallLayoutPresetProvider());
var previewPath = CutPreviewAssetCatalog.CreateCapturePath(Channel, activeItem.Id, "internal-next");
var captured = await _adapter.TryCaptureCutPreviewAsync(
Channel,
template,
previewFrame.Cut,
previewFrame.Snapshot,
station,
imageRootPath,
previewPath,
size.Width,
size.Height,
PreviewFrame,
cancellationToken).ConfigureAwait(false);
if (!captured)
{
activeItem.ClearInternalNextPreview();
return;
}
var regionLabel = string.IsNullOrWhiteSpace(previewFrame.RegionLabel)
? activeItem.SelectionRegionLabel
: previewFrame.RegionLabel;
var displayName = $"{activeItem.FormatName} / {regionLabel}";
await UiDispatcher.EnqueueAsync(() =>
activeItem.UpdateInternalNextPreview(previewPath, displayName, $"다음 지역 변수 적용 캡처 {DateTimeOffset.Now:HH:mm:ss}")).ConfigureAwait(false);
}
private async Task<CutPreviewFrame?> TryBuildPreviewFrameAsync(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
BroadcastStationProfile station,
string imageRootPath,
CancellationToken cancellationToken)
{
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
var resolvedCuts = ResolveCuts(template, station);
if (resolvedCuts.Count == 0)
{
return null;
}
var hasEndScene = KarismaSceneResolver.HasEndScene(template, imageRootPath);
var regionTargets = await _dataRefreshGate
.ResolveScheduleRegionTargetsAsync(queueItem, template, station, cancellationToken)
.ConfigureAwait(false);
if (regionTargets.Count == 0)
{
return null;
}
if (ShouldUseAggregateScheduleSnapshot(template))
{
var aggregateRegionGroups = ResolveAggregateScheduleRegionGroups(template, regionTargets);
for (var groupIndex = 0; groupIndex < aggregateRegionGroups.Count; groupIndex++)
{
var aggregateRegionGroup = aggregateRegionGroups[groupIndex];
var snapshot = await _dataRefreshGate
.GetAggregateScheduleSnapshotAsync(queueItem, template, station, aggregateRegionGroup, cancellationToken)
.ConfigureAwait(false);
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out _))
{
continue;
}
var isLastGroup = groupIndex == aggregateRegionGroups.Count - 1;
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastGroup);
if (playbackCuts.Count == 0)
{
continue;
}
var cut = playbackCuts[0];
return new CutPreviewFrame(cut, snapshot, ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup));
}
return null;
}
for (var regionIndex = 0; regionIndex < regionTargets.Count; regionIndex++)
{
var regionTarget = regionTargets[regionIndex];
var snapshot = await _dataRefreshGate
.GetScheduleSnapshotAsync(queueItem, template, regionTarget, cancellationToken)
.ConfigureAwait(false);
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out _))
{
continue;
}
var isLastRegion = regionIndex == regionTargets.Count - 1;
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastRegion);
if (playbackCuts.Count == 0)
{
continue;
}
return new CutPreviewFrame(playbackCuts[0], snapshot, regionTarget.DisplayName);
}
return null;
}
private async Task<CutPreviewFrame?> TryBuildRegionPreviewFrameAsync(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
BroadcastStationProfile station,
string imageRootPath,
IReadOnlyList<FormatCutDefinition> resolvedCuts,
IReadOnlyList<ScheduleRegionTarget> regionTargets,
int startRegionIndex,
bool hasEndScene,
CancellationToken cancellationToken)
{
for (var regionIndex = startRegionIndex; regionIndex < regionTargets.Count; regionIndex++)
{
var regionTarget = regionTargets[regionIndex];
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
var snapshot = await _dataRefreshGate
.GetScheduleSnapshotAsync(queueItem, template, regionTarget, cancellationToken)
.ConfigureAwait(false);
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out _))
{
continue;
}
var isLastRegion = regionIndex == regionTargets.Count - 1;
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastRegion);
if (playbackCuts.Count == 0)
{
continue;
}
return new CutPreviewFrame(playbackCuts[0], snapshot, regionTarget.DisplayName);
}
return null;
}
private async Task<CutPreviewFrame?> TryBuildAggregatePreviewFrameAsync(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
BroadcastStationProfile station,
IReadOnlyList<FormatCutDefinition> resolvedCuts,
IReadOnlyList<IReadOnlyList<ScheduleRegionTarget>> aggregateRegionGroups,
int startGroupIndex,
bool hasEndScene,
CancellationToken cancellationToken)
{
for (var groupIndex = startGroupIndex; groupIndex < aggregateRegionGroups.Count; groupIndex++)
{
var aggregateRegionGroup = aggregateRegionGroups[groupIndex];
await _dataRefreshGate.WaitForRefreshAsync(cancellationToken).ConfigureAwait(false);
var snapshot = await _dataRefreshGate
.GetAggregateScheduleSnapshotAsync(queueItem, template, station, aggregateRegionGroup, cancellationToken)
.ConfigureAwait(false);
if (!_dataRefreshGate.ValidateSnapshotForFormat(template, snapshot, out _))
{
continue;
}
var isLastGroup = groupIndex == aggregateRegionGroups.Count - 1;
var playbackCuts = ResolvePlaybackCuts(template, resolvedCuts, snapshot, hasEndScene && isLastGroup);
if (playbackCuts.Count == 0)
{
continue;
}
var cut = playbackCuts[0];
return new CutPreviewFrame(cut, snapshot, ResolveAggregateRegionGroupLabel(queueItem, aggregateRegionGroup));
}
return null;
}
private ChannelScheduleItem? GetPreviewNextItem(ChannelScheduleItem activeItem)
{
return GetSequentialNextItem(activeItem, allowWrap: LoopEnabled);
}
private bool ShouldSkipCurrentItem(ChannelScheduleItem queueItem)
{
return _skipCurrentItemId == queueItem.Id;
}
private PreparedCutFrame? TryConsumePreparedFrame(
ChannelScheduleItem queueItem,
FormatTemplateDefinition template,
FormatCutDefinition cut)
{
if (_preparedCutFrame is not { } preparedFrame)
{
return null;
}
if (preparedFrame.Item.Id != queueItem.Id ||
!string.Equals(preparedFrame.Template.Id, template.Id, StringComparison.Ordinal) ||
!CutsMatch(preparedFrame.Cut, cut))
{
if (preparedFrame.Item.Id == queueItem.Id)
{
ClearPreparedFrame(resetState: false);
}
return null;
}
_preparedCutFrame = null;
return preparedFrame;
}
private static bool CutsMatch(FormatCutDefinition left, FormatCutDefinition right)
{
return string.Equals(left.Name, right.Name, StringComparison.Ordinal) &&
left.CandidateStartIndex == right.CandidateStartIndex &&
left.UseEndScene == right.UseEndScene &&
string.Equals(left.SceneIdOverride, right.SceneIdOverride, StringComparison.Ordinal);
}
private void ClearPreparedFrame(bool resetState)
{
if (_preparedCutFrame is not { } preparedFrame)
{
return;
}
_preparedCutFrame = null;
if (!resetState || preparedFrame.Item.State != ScheduleQueueItemState.Sending)
{
return;
}
preparedFrame.Item.State = ScheduleQueueItemState.Queued;
preparedFrame.Item.CurrentRegionLabel = string.Empty;
preparedFrame.Item.ClearRenderedPreview();
preparedFrame.Item.ClearInternalNextPreview();
}
private void ClearSkipCurrentItem(ChannelScheduleItem queueItem)
{
if (_skipCurrentItemId == queueItem.Id)
{
_skipCurrentItemId = null;
}
}
private static bool ShouldUseAggregateScheduleSnapshot(FormatTemplateDefinition template)
{
if (IsCurrentLeaderTemplate(template))
{
return true;
}
if (IsTopPanseTemplate(template))
{
return true;
}
if (IsNormalPanseMapTemplate(template))
{
return true;
}
if (IsBottomWinnerTemplate(template) || IsBasicCouncilWinnerTemplate(template))
{
return true;
}
if (ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return true;
}
if (template.RecommendedChannel == BroadcastChannel.Bottom)
{
return IsBottomTurnoutBoardTemplate(template);
}
return IsNormalPreElectionTurnoutDistrictBoardTemplate(template) ||
IsTopTurnoutDistrictBoardTemplate(template);
}
private static IReadOnlyList<IReadOnlyList<ScheduleRegionTarget>> ResolveAggregateScheduleRegionGroups(
FormatTemplateDefinition template,
IReadOnlyList<ScheduleRegionTarget> regionTargets)
{
if (IsCurrentLeaderTemplate(template))
{
if (IsBottomCurrentLeaderTemplate(template))
{
return [regionTargets];
}
return ChunkRegionTargets(regionTargets, ResolveCurrentLeaderPageSize(template));
}
if (IsBottomWinnerTemplate(template))
{
return [regionTargets];
}
if (IsTopTurnoutDistrictBoardTemplate(template))
{
return regionTargets
.GroupBy(ResolveCouncilSeatTableRegionKey, StringComparer.OrdinalIgnoreCase)
.SelectMany(group => ChunkRegionTargets(group.ToArray(), 3))
.ToArray();
}
if (IsNormalPreElectionTurnoutDistrictBoardTemplate(template))
{
return regionTargets
.GroupBy(ResolveCouncilSeatTableRegionKey, StringComparer.OrdinalIgnoreCase)
.SelectMany(group => ChunkRegionTargets(group.ToArray(), 7))
.ToArray();
}
if (!IsBasicCouncilWinnerTemplate(template) &&
!ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name))
{
return [regionTargets];
}
return regionTargets
.GroupBy(ResolveCouncilSeatTableRegionKey, StringComparer.OrdinalIgnoreCase)
.Select(group => (IReadOnlyList<ScheduleRegionTarget>)group.ToArray())
.ToArray();
}
private static IReadOnlyList<IReadOnlyList<ScheduleRegionTarget>> ChunkRegionTargets(
IReadOnlyList<ScheduleRegionTarget> regionTargets,
int pageSize)
{
pageSize = Math.Max(1, pageSize);
var groups = new List<IReadOnlyList<ScheduleRegionTarget>>();
for (var index = 0; index < regionTargets.Count; index += pageSize)
{
groups.Add(regionTargets.Skip(index).Take(pageSize).ToArray());
}
return groups;
}
private static string ResolveCouncilSeatTableRegionKey(ScheduleRegionTarget target)
{
if (!string.IsNullOrWhiteSpace(target.RegionName))
{
return NormalizeRegionKey(target.RegionName);
}
if (!string.IsNullOrWhiteSpace(target.DisplayName))
{
return NormalizeRegionKey(target.DisplayName);
}
return target.DistrictCode ?? string.Empty;
}
private static string ResolveAggregateRegionGroupLabel(
ChannelScheduleItem queueItem,
IReadOnlyList<ScheduleRegionTarget> regionTargets)
{
if (queueItem.Channel == BroadcastChannel.Normal &&
string.Equals(queueItem.FormatName, "판세_광역단체장", StringComparison.Ordinal))
{
return "전국";
}
var regionNames = regionTargets
.Select(target => target.RegionName)
.Where(regionName => !string.IsNullOrWhiteSpace(regionName))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (regionNames.Length == 1)
{
return regionNames[0];
}
return string.IsNullOrWhiteSpace(queueItem.SelectionRegionLabel)
? "선택권역"
: queueItem.SelectionRegionLabel;
}
private static bool IsTopTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.TopLeft &&
string.Equals(template.Name, "투표율_선거구별", StringComparison.Ordinal);
}
private static bool IsNormalPreElectionTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Normal &&
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
}
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Bottom &&
(string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) ||
string.Equals(template.Name, "사전투표율_시도", StringComparison.Ordinal) ||
string.Equals(template.Name, "사전투표율_시군구", StringComparison.Ordinal) ||
string.Equals(template.Name, "투표율", StringComparison.Ordinal) ||
string.Equals(template.Name, "투표율_시도", StringComparison.Ordinal) ||
string.Equals(template.Name, "투표율_시군구", StringComparison.Ordinal));
}
private static bool IsTopPanseTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.TopLeft &&
template.Name.StartsWith("판세_", StringComparison.Ordinal);
}
private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Normal &&
string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal);
}
private static string NormalizeRegionKey(string value)
{
return string.Concat((value ?? string.Empty).Where(character => !char.IsWhiteSpace(character)));
}
private static IReadOnlyList<FormatCutDefinition> ResolvePlaybackCuts(
FormatTemplateDefinition template,
IReadOnlyList<FormatCutDefinition> baseCuts,
ElectionDataSnapshot snapshot,
bool useEndSceneOnLastCut)
{
if (!IsCandidatePagedTemplate(template) || baseCuts.Count == 0)
{
return ApplyEndSceneToLastCut(baseCuts, useEndSceneOnLastCut);
}
var candidateCount = Math.Max(snapshot.Candidates.Count, 1);
var pageSize = ResolveCandidatePageSize(template);
var pageStarts = Enumerable
.Range(0, (int)Math.Ceiling(candidateCount / (double)pageSize))
.Select(pageIndex => pageIndex * pageSize)
.ToArray();
var playbackCuts = new List<FormatCutDefinition>(baseCuts.Count * candidateCount);
foreach (var baseCut in baseCuts)
{
foreach (var candidateStartIndex in pageStarts)
{
var isLastPage = candidateStartIndex == pageStarts[^1];
var cutName = ResolveCandidatePagedCutName(template, baseCut.Name, candidateStartIndex, isLastPage);
playbackCuts.Add(new FormatCutDefinition
{
Name = cutName,
DurationSeconds = baseCut.DurationSeconds,
CandidateStartIndex = candidateStartIndex,
UseEndScene = baseCut.UseEndScene,
SceneIdOverride = ResolveCareerSceneId(template, cutName)
});
}
}
return IsAllCandidateTemplate(template)
? playbackCuts
: ApplyEndSceneToLastCut(playbackCuts, useEndSceneOnLastCut);
}
private static string ResolveCandidatePagedCutName(
FormatTemplateDefinition template,
string cutName,
int candidateStartIndex,
bool isLastPage)
{
if (IsAllCandidateTemplate(template))
{
if (candidateStartIndex == 0)
{
return cutName;
}
if (template.RecommendedChannel == BroadcastChannel.Bottom)
{
return ResolveSuffixedCutName(cutName, "_loop");
}
if (isLastPage)
{
return ResolveSuffixedCutName(cutName, "_END");
}
return UsesAllCandidateLoopScene(template)
? ResolveSuffixedCutName(cutName, "_loop")
: cutName;
}
return candidateStartIndex == 0
? cutName
: ResolveSuffixedCutName(cutName, "_loop");
}
private static string ResolveSuffixedCutName(string cutName, string suffix)
{
if (cutName.EndsWith(suffix, StringComparison.Ordinal))
{
return cutName;
}
const string inSuffix = "_in";
if (string.Equals(suffix, "_loop", StringComparison.Ordinal) &&
cutName.EndsWith(inSuffix, StringComparison.Ordinal))
{
return cutName[..^inSuffix.Length] + suffix;
}
return cutName + suffix;
}
private static string ResolveCareerSceneId(FormatTemplateDefinition template, string cutName)
{
var folderName = Path.GetDirectoryName(template.Id);
return string.IsNullOrWhiteSpace(folderName)
? cutName
: Path.Combine(folderName, cutName);
}
private static IReadOnlyList<FormatCutDefinition> ApplyEndSceneToLastCut(
IReadOnlyList<FormatCutDefinition> cuts,
bool useEndSceneOnLastCut)
{
if (!useEndSceneOnLastCut || cuts.Count == 0)
{
return cuts;
}
var result = cuts.ToArray();
result[^1] = ResolveScheduledCut(result[^1], hasEndScene: true, useEndScene: true);
return result;
}
private static FormatCutDefinition ResolveScheduledCut(
FormatCutDefinition cut,
bool hasEndScene,
bool useEndScene)
{
if (!hasEndScene || !useEndScene || cut.UseEndScene)
{
return cut;
}
return new FormatCutDefinition
{
Name = cut.Name,
DurationSeconds = cut.DurationSeconds,
CandidateStartIndex = cut.CandidateStartIndex,
UseEndScene = true,
SceneIdOverride = cut.SceneIdOverride
};
}
private static bool IsCareerTemplate(FormatTemplateDefinition template)
{
return template.Name.StartsWith("경력_", StringComparison.Ordinal);
}
private static bool IsAllCandidateTemplate(FormatTemplateDefinition template)
{
return template.Name.StartsWith("모든후보_", StringComparison.Ordinal) ||
template.Name.StartsWith("전후보_", StringComparison.Ordinal);
}
private static bool IsCurrentLeaderTemplate(FormatTemplateDefinition template)
{
return template.Name.StartsWith("이시각1위_", StringComparison.Ordinal) ||
IsBottomCurrentLeaderTemplate(template);
}
private static bool IsBottomCurrentLeaderTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Bottom &&
template.Name.StartsWith("1위_", StringComparison.Ordinal);
}
private static bool IsCandidatePagedTemplate(FormatTemplateDefinition template)
{
return IsCareerTemplate(template) ||
IsAllCandidateTemplate(template) ||
IsBottomWinnerTemplate(template) ||
IsBottomCurrentLeaderTemplate(template);
}
private static bool IsBottomWinnerTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Bottom &&
template.Name.StartsWith("당선_", StringComparison.Ordinal);
}
private static bool IsBasicCouncilWinnerTemplate(FormatTemplateDefinition template)
{
return template.Name.StartsWith("당선_", StringComparison.Ordinal) &&
(template.Name.Contains("광역의원", StringComparison.Ordinal) ||
template.Name.Contains("기초의원", StringComparison.Ordinal));
}
private static int ResolveCandidatePageSize(FormatTemplateDefinition template)
{
if (IsBottomWinnerTemplate(template) ||
IsBottomCurrentLeaderTemplate(template) ||
(template.RecommendedChannel == BroadcastChannel.Bottom && IsAllCandidateTemplate(template)))
{
return 3;
}
if (!IsAllCandidateTemplate(template))
{
return 1;
}
return template.SceneWidth >= 5000 ||
template.Name.Contains("8316", StringComparison.Ordinal) ||
template.Name.Contains("3840", StringComparison.Ordinal) ||
template.Name.Contains("2880", StringComparison.Ordinal) ||
template.Name.Contains("5760", StringComparison.Ordinal) ||
template.Id.Contains("_L", StringComparison.Ordinal)
? 3
: 1;
}
private static int ResolveCurrentLeaderPageSize(FormatTemplateDefinition template)
{
if (template.RecommendedChannel == BroadcastChannel.Bottom &&
template.Name.StartsWith("1위_", StringComparison.Ordinal))
{
return 3;
}
if (template.Name.Contains("_L", StringComparison.Ordinal) ||
template.Id.Contains("_L", StringComparison.Ordinal) ||
template.SceneWidth >= 5000)
{
return 3;
}
if (template.Name.Contains("_HD", StringComparison.Ordinal) ||
template.Id.Contains("_HD", StringComparison.Ordinal))
{
return 2;
}
return 1;
}
private static bool UsesAllCandidateLoopScene(FormatTemplateDefinition template)
{
return ResolveCandidatePageSize(template) == 1;
}
private IReadOnlyList<FormatCutDefinition> ResolveCuts(FormatTemplateDefinition template, BroadcastStationProfile station)
{
if (template.LoopMode != LoopMode.StationRegions)
{
return template.Cuts;
}
var loopCuts = new List<FormatCutDefinition>();
foreach (var region in station.RegionFilters)
{
loopCuts.Add(new FormatCutDefinition
{
Name = $"{template.Cuts[0].Name} - {region}",
DurationSeconds = template.Cuts[0].DurationSeconds
});
}
return loopCuts;
}
private ChannelScheduleItem? GetNextPlayableItem()
{
if (_preparedCutFrame is { Item: var preparedItem } && Queue.Contains(preparedItem))
{
return preparedItem;
}
var anchorItem = ActivePlaybackItem ?? GetLastPlaybackItem();
return GetSequentialNextItem(anchorItem, allowWrap: LoopEnabled);
}
public void RefreshQueueMarkers()
{
var activeItem = Queue.FirstOrDefault(item => item.State is ScheduleQueueItemState.OnAir or ScheduleQueueItemState.Sending);
var anchorItem = activeItem ?? GetLastPlaybackItem();
var nextItem = GetSequentialNextItem(anchorItem, allowWrap: LoopEnabled);
foreach (var item in Queue)
{
if (item == activeItem || item.State == ScheduleQueueItemState.Error)
{
continue;
}
item.State = item == nextItem ? ScheduleQueueItemState.Next : ScheduleQueueItemState.Queued;
}
}
private ChannelScheduleItem? GetLastPlaybackItem()
{
if (_lastPlaybackItemId is not { } itemId)
{
return null;
}
return Queue.FirstOrDefault(item => item.Id == itemId);
}
private ChannelScheduleItem? GetSequentialNextItem(ChannelScheduleItem? anchorItem, bool allowWrap)
{
if (Queue.Count == 0)
{
return null;
}
if (anchorItem is null)
{
return Queue.FirstOrDefault(IsPlayableQueueItem);
}
var anchorIndex = Queue.IndexOf(anchorItem);
if (anchorIndex < 0)
{
return Queue.FirstOrDefault(IsPlayableQueueItem);
}
var nextItem = Queue.Skip(anchorIndex + 1).FirstOrDefault(IsPlayableQueueItem);
if (nextItem is not null)
{
return nextItem;
}
return allowWrap
? Queue.Take(anchorIndex + 1).FirstOrDefault(IsPlayableQueueItem)
: null;
}
private static bool IsPlayableQueueItem(ChannelScheduleItem item)
{
return item.State is ScheduleQueueItemState.Queued or ScheduleQueueItemState.Next or ScheduleQueueItemState.Completed;
}
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);
}