1691 lines
60 KiB
C#
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);
|
|
}
|