Files
MBN_STOCK_WEBVIEW/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs

1419 lines
48 KiB
C#

using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Registration;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
using MBN_STOCK_WEBVIEW.Playout.Safety;
namespace MBN_STOCK_WEBVIEW.Playout;
internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
private const string ProcessChangedMessage =
"Tornado 프로세스가 변경되어 안전한 재연결이 필요합니다.";
private readonly ValidatedPlayoutOptions _options;
private readonly IK3dSessionFactory _sessionFactory;
private readonly IK3dRegistrationProbe _registrationProbe;
private readonly ITornadoProcessProbe _processProbe;
private readonly IStaDispatcher? _dispatcher;
private readonly ILiveAuthorization _liveAuthorization;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly CancellationTokenSource _monitorCancellation = new();
private readonly object _statusSync = new();
private readonly Task _monitorTask;
private IK3dSession? _session;
private TornadoProcessSnapshot? _connectedProcessSnapshot;
private PlayoutCue? _preparedCue;
private string? _onAirSceneName;
private TornadoProcessSnapshot _processSnapshot = new(0, 0, 0);
private PlayoutConnectionState _connectionState;
private DateTimeOffset _nextReconnectAt;
private int _reconnectAttempts;
private long _sequence;
private int _lastKtapConnectState;
private bool _connectionRequested;
private volatile bool _outcomeUnknown;
private volatile bool _disposed;
private int _disposeStarted;
internal TornadoPlayoutEngine(
ValidatedPlayoutOptions options,
IK3dSessionFactory sessionFactory,
IK3dRegistrationProbe registrationProbe,
ITornadoProcessProbe processProbe,
IStaDispatcher? dispatcher,
ILiveAuthorization liveAuthorization,
TimeProvider timeProvider,
bool startMonitor = true)
{
_options = options;
_sessionFactory = sessionFactory;
_registrationProbe = registrationProbe;
_processProbe = processProbe;
_dispatcher = dispatcher;
_liveAuthorization = liveAuthorization;
_timeProvider = timeProvider;
_connectionState = options.Mode switch
{
PlayoutMode.Disabled => PlayoutConnectionState.Disabled,
PlayoutMode.DryRun => PlayoutConnectionState.DryRunReady,
_ => PlayoutConnectionState.Disconnected
};
if (startMonitor && options.Mode != PlayoutMode.Disabled)
{
_processSnapshot = CaptureProcessSnapshot();
}
Status = BuildStatus(InitialMessage(options.Mode));
_monitorTask = startMonitor && options.Mode != PlayoutMode.Disabled
? Task.Run(MonitorLoopAsync)
: Task.CompletedTask;
}
public PlayoutStatus Status { get; private set; }
public event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged;
public Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default) =>
SerializedAsync(PlayoutOperation.Connect, cancellationToken, ConnectCoreAsync);
public Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default) =>
SerializedAsync(PlayoutOperation.Disconnect, cancellationToken, DisconnectCoreAsync);
public Task<PlayoutResult> PrepareAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cue);
return SerializedAsync(
PlayoutOperation.Prepare,
cancellationToken,
token => PrepareCoreAsync(cue, token));
}
public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default) =>
SerializedAsync(PlayoutOperation.TakeIn, cancellationToken, TakeInCoreAsync);
public Task<PlayoutResult> NextAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cue);
return SerializedAsync(
PlayoutOperation.Next,
cancellationToken,
token => NextCoreAsync(cue, token));
}
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default) =>
SerializedAsync(
PlayoutOperation.TakeOut,
cancellationToken,
token => TakeOutCoreAsync(scope, token));
public async ValueTask QuarantineAsync()
{
await _gate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
try
{
if (_disposed)
{
return;
}
_outcomeUnknown = true;
_connectionRequested = false;
_connectedProcessSnapshot = null;
_preparedCue = null;
_onAirSceneName = null;
_connectionState = PlayoutConnectionState.OutcomeUnknown;
var session = _session;
_session = null;
if (session is not null)
{
await AbandonSessionOnStaAsync(session).ConfigureAwait(false);
}
PublishStatus("송출 결과를 확인할 수 없어 연결을 격리했습니다. 출력 상태를 직접 확인하세요.");
}
finally
{
_gate.Release();
}
}
public async ValueTask DisposeAsync()
{
if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) != 0)
{
return;
}
_disposed = true;
_connectionRequested = false;
_monitorCancellation.Cancel();
try
{
await _monitorTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
await _gate.WaitAsync().ConfigureAwait(false);
try
{
if (_session is not null && _dispatcher is not null && !_dispatcher.IsQuarantined)
{
if (_onAirSceneName is not null || _outcomeUnknown)
{
_outcomeUnknown = true;
await AbandonCurrentSessionAsync().ConfigureAwait(false);
}
else
{
await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false);
}
}
_preparedCue = null;
_onAirSceneName = null;
_connectionState = PlayoutConnectionState.Disposed;
PublishStatus("송출 어댑터가 종료되었습니다.");
}
finally
{
_gate.Release();
}
if (_dispatcher is not null)
{
await _dispatcher.DisposeAsync().ConfigureAwait(false);
}
_monitorCancellation.Dispose();
}
internal async Task PollProcessOnceAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_disposed)
{
return;
}
var previousSnapshot = _processSnapshot;
_processSnapshot = CaptureProcessSnapshot();
if (_options.Mode == PlayoutMode.DryRun)
{
if (_processSnapshot != previousSnapshot)
{
PublishStatus(DryRunMonitoringMessage);
}
return;
}
if (_options.Mode is not (PlayoutMode.Test or PlayoutMode.Live))
{
return;
}
if (_outcomeUnknown)
{
_connectionState = PlayoutConnectionState.OutcomeUnknown;
PublishStatus("송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요.");
return;
}
var eligible = IsProcessEligible(_processSnapshot);
if (!eligible)
{
_reconnectAttempts = 0;
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
if (_session is not null && !_outcomeUnknown)
{
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
}
_preparedCue = null;
_onAirSceneName = null;
ScheduleReconnect();
_connectionState = _connectionRequested
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Disconnected;
PublishStatus(ProcessUnavailableMessage(_processSnapshot));
return;
}
if (HasConnectedProcessGenerationChanged(_processSnapshot))
{
_reconnectAttempts = 0;
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
_preparedCue = null;
_onAirSceneName = null;
ScheduleReconnect();
_connectionState = _connectionRequested
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Disconnected;
}
if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled ||
_outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts ||
_timeProvider.GetUtcNow() < _nextReconnectAt)
{
PublishStatus(CurrentMessage());
return;
}
_reconnectAttempts++;
var result = await ConnectActualAsync(reconnecting: true, cancellationToken)
.ConfigureAwait(false);
if (!result.IsSuccess)
{
ScheduleReconnect();
}
}
finally
{
_gate.Release();
}
}
private async Task<PlayoutResult> SerializedAsync(
PlayoutOperation operation,
CancellationToken cancellationToken,
Func<CancellationToken, Task<PlayoutResult>> callback)
{
try
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// A caller can be cancelled while another serialized command is still
// deciding whether its COM outcome is known. Wait for that decision so an
// existing OutcomeUnknown latch can never be downgraded to retryable Cancelled.
await _gate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
try
{
if (_disposed)
{
return _outcomeUnknown
? ExistingOutcomeUnknown(operation)
: Result(operation, PlayoutResultCode.Unavailable, "송출 어댑터가 종료되었습니다.");
}
return _outcomeUnknown
? ExistingOutcomeUnknown(operation)
: Result(operation, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다.");
}
finally
{
_gate.Release();
}
}
try
{
if (_disposed)
{
return _outcomeUnknown
? ExistingOutcomeUnknown(operation)
: Result(operation, PlayoutResultCode.Unavailable, "송출 어댑터가 종료되었습니다.");
}
if (_outcomeUnknown)
{
return ExistingOutcomeUnknown(operation);
}
return await callback(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
private Task<PlayoutResult> ConnectCoreAsync(CancellationToken cancellationToken)
{
if (_options.Mode == PlayoutMode.Disabled)
{
return Task.FromResult(Result(
PlayoutOperation.Connect,
PlayoutResultCode.Rejected,
"송출 기능이 비활성화되어 있습니다."));
}
if (_options.Mode == PlayoutMode.DryRun)
{
_connectionState = PlayoutConnectionState.DryRunReady;
var message = DryRunOperationMessage("연결 요청을 확인했습니다.");
PublishStatus(message);
return Task.FromResult(Result(
PlayoutOperation.Connect,
PlayoutResultCode.Success,
message));
}
_connectionRequested = true;
_reconnectAttempts = 0;
if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized())
{
_connectionRequested = false;
_connectionState = PlayoutConnectionState.Disconnected;
PublishStatus("라이브 송출 승인이 필요합니다.");
return Task.FromResult(Result(
PlayoutOperation.Connect,
PlayoutResultCode.Rejected,
"라이브 송출 승인이 필요합니다."));
}
return ConnectActualAsync(reconnecting: false, cancellationToken);
}
private async Task<PlayoutResult> ConnectActualAsync(
bool reconnecting,
CancellationToken cancellationToken)
{
if (_outcomeUnknown || _dispatcher is null || _dispatcher.IsQuarantined)
{
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
_processSnapshot = CaptureProcessSnapshot();
if (!IsProcessEligible(_processSnapshot))
{
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
if (_session is not null)
{
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
_preparedCue = null;
_onAirSceneName = null;
}
_connectionState = reconnecting
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Disconnected;
PublishStatus(ProcessUnavailableMessage(_processSnapshot));
return Result(
PlayoutOperation.Connect,
PlayoutResultCode.Unavailable,
ProcessUnavailableMessage(_processSnapshot));
}
if (_session is not null && !HasConnectedProcessGenerationChanged(_processSnapshot))
{
_connectionState = PlayoutConnectionState.Connected;
PublishStatus("Tornado 테스트 송출에 연결되었습니다.");
return Result(PlayoutOperation.Connect, PlayoutResultCode.Success, "송출 연결이 준비되었습니다.");
}
if (_session is not null)
{
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
_preparedCue = null;
_onAirSceneName = null;
}
var processSnapshotBeforeConnect = _processSnapshot;
K3dRegistrationReport registration;
try
{
registration = _registrationProbe.Probe();
}
catch (Exception)
{
_connectionState = PlayoutConnectionState.Faulted;
PublishStatus("K3D 송출 구성 요소를 확인할 수 없습니다.");
return Result(
PlayoutOperation.Connect,
PlayoutResultCode.Unavailable,
"K3D 송출 구성 요소를 확인할 수 없습니다.");
}
if (!registration.IsReady)
{
_connectionState = PlayoutConnectionState.Faulted;
PublishStatus(registration.Message);
return Result(PlayoutOperation.Connect, PlayoutResultCode.Unavailable, registration.Message);
}
_connectionState = reconnecting
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Connecting;
PublishStatus(reconnecting ? "Tornado 테스트 송출에 다시 연결하는 중입니다." : "Tornado 송출에 연결하는 중입니다.");
IK3dSession? pendingSession = null;
try
{
_session = await _dispatcher.InvokeAsync(
() =>
{
var session = _sessionFactory.Create();
Volatile.Write(ref pendingSession, session);
try
{
session.Connect(_options);
return session;
}
catch
{
session.Dispose();
throw;
}
},
_options.ConnectTimeout,
cancellationToken,
static lateSession => lateSession.Dispose(),
() => Volatile.Read(ref pendingSession)?.Dispose()).ConfigureAwait(false);
CaptureKtapEvidence(_session);
_processSnapshot = CaptureProcessSnapshot();
if (!IsProcessEligible(_processSnapshot) ||
!processSnapshotBeforeConnect.HasSameProcessGeneration(_processSnapshot))
{
if (!await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false))
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
ScheduleReconnect();
_connectionState = reconnecting
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Disconnected;
var processMessage = IsProcessEligible(_processSnapshot)
? ProcessChangedMessage
: ProcessUnavailableMessage(_processSnapshot);
PublishStatus(processMessage);
return Result(
PlayoutOperation.Connect,
PlayoutResultCode.Unavailable,
processMessage);
}
_connectedProcessSnapshot = _processSnapshot;
_connectionState = PlayoutConnectionState.Connected;
_reconnectAttempts = 0;
_preparedCue = null;
_onAirSceneName = null;
PublishStatus("KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다.");
return Result(
PlayoutOperation.Connect,
PlayoutResultCode.Success,
"KTAPConnect 요청이 수락되었습니다. 서버 콜백은 운영자 확인이 필요합니다.");
}
catch (OperationCanceledException)
{
PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession));
_connectionState = PlayoutConnectionState.Disconnected;
PublishStatus("송출 연결 요청이 취소되었습니다.");
return Result(PlayoutOperation.Connect, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다.");
}
catch (StaOperationTimedOutException)
{
PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession));
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
catch (StaDispatcherQuarantinedException)
{
PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession));
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
catch (Exception)
{
CaptureKtapEvidence(Volatile.Read(ref pendingSession));
_session = null;
ScheduleReconnect();
_connectionState = reconnecting
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Faulted;
PublishStatus("Tornado 송출에 연결할 수 없습니다.");
return Result(PlayoutOperation.Connect, PlayoutResultCode.Failed, "Tornado 송출에 연결할 수 없습니다.");
}
}
private async Task<PlayoutResult> DisconnectCoreAsync(CancellationToken cancellationToken)
{
_connectionRequested = false;
_reconnectAttempts = 0;
if (_options.Mode == PlayoutMode.Disabled)
{
return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다.");
}
if (_options.Mode == PlayoutMode.DryRun)
{
_preparedCue = null;
_onAirSceneName = null;
_connectionState = PlayoutConnectionState.DryRunReady;
var message = DryRunOperationMessage("연결 해제를 확인했습니다.");
PublishStatus(message);
return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Success, message);
}
if (_outcomeUnknown || _dispatcher?.IsQuarantined == true)
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
_connectionState = PlayoutConnectionState.Disconnecting;
PublishStatus("Tornado 송출 연결을 해제하는 중입니다.");
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
_preparedCue = null;
_onAirSceneName = null;
_connectionState = PlayoutConnectionState.Disconnected;
PublishStatus("Tornado 송출 연결이 해제되었습니다.");
return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Success, "송출 연결이 해제되었습니다.");
}
private Task<PlayoutResult> PrepareCoreAsync(PlayoutCue cue, CancellationToken cancellationToken)
{
if (_options.Mode is PlayoutMode.Disabled)
{
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다."));
}
if (_options.Mode == PlayoutMode.DryRun)
{
_preparedCue = cue with { SceneName = SafeSceneName(cue) };
var message = DryRunOperationMessage("장면 준비를 확인했습니다.");
PublishStatus(message);
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Success, message));
}
if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized())
{
return Task.FromResult(Result(
PlayoutOperation.Prepare,
PlayoutResultCode.Rejected,
"라이브 송출 승인이 필요합니다."));
}
PlayoutCue resolved;
try
{
resolved = _options.ResolveCue(cue);
}
catch (PlayoutRequestException exception)
{
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, exception.Message));
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved))
{
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다."));
}
return PrepareActualAsync(resolved, cancellationToken);
}
private async Task<PlayoutResult> PrepareActualAsync(
PlayoutCue cue,
CancellationToken cancellationToken)
{
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.Prepare, cancellationToken)
.ConfigureAwait(false);
if (availability is not null)
{
return availability;
}
var result = await RunSessionOperationAsync(
PlayoutOperation.Prepare,
session => session.Prepare(cue, _options.LayoutIndex),
partialOutcomeUnknown: false,
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_preparedCue = cue;
PublishStatus("장면이 준비되었습니다.");
}
return result;
}
private async Task<PlayoutResult> TakeInCoreAsync(CancellationToken cancellationToken)
{
if (_options.Mode == PlayoutMode.Disabled)
{
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다.");
}
if (_preparedCue is null)
{
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "먼저 장면을 준비해야 합니다.");
}
if (_options.Mode == PlayoutMode.DryRun)
{
_onAirSceneName = SafeSceneName(_preparedCue);
_preparedCue = null;
var message = DryRunOperationMessage("TAKE IN을 확인했습니다.");
PublishStatus(message);
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Success, message);
}
if (!CanTakeIn())
{
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다.");
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(_preparedCue))
{
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.");
}
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.TakeIn, cancellationToken)
.ConfigureAwait(false);
if (availability is not null)
{
return availability;
}
var prepared = _preparedCue;
var result = await RunSessionOperationAsync(
PlayoutOperation.TakeIn,
session => session.Play(_options.LayoutIndex),
partialOutcomeUnknown: true,
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_onAirSceneName = SafeSceneName(prepared);
_preparedCue = null;
PublishStatus("장면이 송출되었습니다.");
}
return result;
}
private async Task<PlayoutResult> NextCoreAsync(PlayoutCue cue, CancellationToken cancellationToken)
{
if (_onAirSceneName is null)
{
return Result(
PlayoutOperation.Next,
PlayoutResultCode.Rejected,
"NEXT는 송출 중인 장면이 있을 때만 사용할 수 있습니다.");
}
if (_options.Mode == PlayoutMode.Disabled)
{
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다.");
}
if (_options.Mode == PlayoutMode.DryRun)
{
_onAirSceneName = SafeSceneName(cue);
_preparedCue = null;
var message = DryRunOperationMessage("NEXT를 확인했습니다.");
PublishStatus(message);
return Result(PlayoutOperation.Next, PlayoutResultCode.Success, message);
}
if (!CanTakeIn())
{
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다.");
}
PlayoutCue resolved;
try
{
resolved = _options.ResolveCue(cue);
}
catch (PlayoutRequestException exception)
{
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, exception.Message);
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved))
{
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.");
}
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.Next, cancellationToken)
.ConfigureAwait(false);
if (availability is not null)
{
return availability;
}
var result = await RunSessionOperationAsync(
PlayoutOperation.Next,
session =>
{
session.Prepare(resolved, _options.LayoutIndex);
session.Play(_options.LayoutIndex);
},
partialOutcomeUnknown: true,
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_onAirSceneName = SafeSceneName(resolved);
_preparedCue = null;
PublishStatus("다음 장면이 송출되었습니다.");
}
return result;
}
private async Task<PlayoutResult> TakeOutCoreAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken)
{
if (!Enum.IsDefined(scope))
{
return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Rejected, "TAKE OUT 범위가 올바르지 않습니다.");
}
if (_options.Mode == PlayoutMode.Disabled)
{
return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다.");
}
if (_options.Mode == PlayoutMode.DryRun)
{
_preparedCue = null;
_onAirSceneName = null;
var message = DryRunOperationMessage("TAKE OUT을 확인했습니다.");
PublishStatus(message);
return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Success, message);
}
if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized())
{
return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다.");
}
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.TakeOut, cancellationToken)
.ConfigureAwait(false);
if (availability is not null)
{
return availability;
}
var result = await RunSessionOperationAsync(
PlayoutOperation.TakeOut,
session => session.TakeOut(_options.LayoutIndex, scope),
partialOutcomeUnknown: true,
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_preparedCue = null;
_onAirSceneName = null;
PublishStatus("장면 송출이 종료되었습니다.");
}
return result;
}
private async Task<PlayoutResult?> EnsureCommandAvailableAsync(
PlayoutOperation operation,
CancellationToken cancellationToken)
{
if (_outcomeUnknown || _dispatcher?.IsQuarantined == true)
{
return MarkOutcomeUnknown(operation);
}
_processSnapshot = CaptureProcessSnapshot();
if (!IsProcessEligible(_processSnapshot))
{
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(operation);
}
if (_session is not null)
{
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(operation);
}
}
_preparedCue = null;
_onAirSceneName = null;
_connectionState = PlayoutConnectionState.Reconnecting;
PublishStatus(ProcessUnavailableMessage(_processSnapshot));
return Result(operation, PlayoutResultCode.Unavailable, ProcessUnavailableMessage(_processSnapshot));
}
if (HasConnectedProcessGenerationChanged(_processSnapshot))
{
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(operation);
}
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(operation);
}
_preparedCue = null;
_onAirSceneName = null;
ScheduleReconnect();
_connectionState = _connectionRequested
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Disconnected;
PublishStatus(ProcessChangedMessage);
return Result(
operation,
PlayoutResultCode.Unavailable,
ProcessChangedMessage);
}
if (_session is null)
{
return Result(operation, PlayoutResultCode.Unavailable, "Tornado 송출 연결이 필요합니다.");
}
return null;
}
private async Task<PlayoutResult> RunSessionOperationAsync(
PlayoutOperation operation,
Action<IK3dSession> callback,
bool partialOutcomeUnknown,
CancellationToken cancellationToken)
{
var session = _session!;
var callbackStarted = 0;
var preserveActiveOutput = partialOutcomeUnknown || _onAirSceneName is not null;
void CleanupAbandonedOperation()
{
if (preserveActiveOutput)
{
session.Abandon();
}
else
{
session.Dispose();
}
}
try
{
await _dispatcher!.InvokeAsync(
() =>
{
Volatile.Write(ref callbackStarted, 1);
try
{
callback(session);
}
finally
{
if (_dispatcher.IsQuarantined)
{
CleanupAbandonedOperation();
}
}
return true;
},
_options.OperationTimeout,
cancellationToken,
_ => CleanupAbandonedOperation(),
CleanupAbandonedOperation).ConfigureAwait(false);
return Result(operation, PlayoutResultCode.Success, SuccessMessage(operation));
}
catch (OperationCanceledException)
{
if (preserveActiveOutput && Volatile.Read(ref callbackStarted) != 0)
{
_session = null;
_connectedProcessSnapshot = null;
await AbandonSessionOnStaAsync(session).ConfigureAwait(false);
return MarkOutcomeUnknown(operation);
}
return Result(operation, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다.");
}
catch (StaOperationTimedOutException)
{
return MarkOutcomeUnknown(operation);
}
catch (StaDispatcherQuarantinedException)
{
return MarkOutcomeUnknown(operation);
}
catch (Exception)
{
if (preserveActiveOutput)
{
_session = null;
_connectedProcessSnapshot = null;
await AbandonSessionOnStaAsync(session).ConfigureAwait(false);
return MarkOutcomeUnknown(operation);
}
_session = session;
if (!await RecycleFailedSessionAsync().ConfigureAwait(false))
{
return MarkOutcomeUnknown(operation);
}
return Result(
operation,
PlayoutResultCode.Failed,
"송출 명령을 처리할 수 없습니다.");
}
}
private async Task<bool> RecycleFailedSessionAsync()
{
_preparedCue = null;
_onAirSceneName = null;
var released = await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false);
if (!released)
{
return false;
}
_connectionState = _connectionRequested
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Faulted;
ScheduleReconnect();
PublishStatus("Tornado 송출 세션을 다시 연결해야 합니다.");
return true;
}
private async Task<bool> ReleaseSessionAsync(CancellationToken cancellationToken)
{
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return false;
}
var session = _session;
_session = null;
_connectedProcessSnapshot = null;
if (session is null)
{
return true;
}
if (_dispatcher is null || _dispatcher.IsQuarantined)
{
return false;
}
try
{
await CleanupSessionOnStaAsync(session, cancellationToken).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
if (_dispatcher.IsQuarantined)
{
return false;
}
try
{
await CleanupSessionOnStaAsync(session, CancellationToken.None).ConfigureAwait(false);
return true;
}
catch (Exception)
{
return false;
}
}
catch (Exception)
{
return false;
}
}
private async Task<bool> AbandonSessionOnStaAsync(IK3dSession session)
{
if (_dispatcher is null || _dispatcher.IsQuarantined)
{
return false;
}
try
{
await _dispatcher.InvokeAsync(
() =>
{
session.Abandon();
return true;
},
_options.DisconnectTimeout,
CancellationToken.None,
_ => session.Abandon(),
() => session.Abandon()).ConfigureAwait(false);
return true;
}
catch
{
// Never fall back to Disconnect when the physical output state is unknown.
return false;
}
}
private async Task AbandonCurrentSessionAsync()
{
var session = _session;
_session = null;
_connectedProcessSnapshot = null;
if (session is not null)
{
await AbandonSessionOnStaAsync(session).ConfigureAwait(false);
}
}
private Task<bool> CleanupSessionOnStaAsync(
IK3dSession session,
CancellationToken cancellationToken) =>
_dispatcher!.InvokeAsync(
() =>
{
try
{
session.Disconnect();
}
finally
{
session.Dispose();
}
return true;
},
_options.DisconnectTimeout,
cancellationToken);
private async Task MonitorLoopAsync()
{
var cancellationToken = _monitorCancellation.Token;
try
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await PollProcessOnceAsync(cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch (Exception)
{
// Process inspection must never terminate the app or the monitor loop.
}
await Task.Delay(
_options.ProcessPollInterval,
_timeProvider,
cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
}
private TornadoProcessSnapshot CaptureProcessSnapshot()
{
try
{
return _processProbe.Capture(
_options.Mode == PlayoutMode.Test
? _options.TestProcessWindowTitleRegex
: null);
}
catch (Exception)
{
return new TornadoProcessSnapshot(0, 0, 0)
{
IdentityInspectionSucceeded = false
};
}
}
private bool IsProcessEligible(TornadoProcessSnapshot snapshot) => _options.Mode switch
{
PlayoutMode.Test =>
snapshot.IdentityInspectionSucceeded &&
snapshot.TotalProcessCount == 1 &&
snapshot.EligibleProcessCount == 1 &&
snapshot.ProgramProcessCount == 0,
PlayoutMode.Live => snapshot.IdentityInspectionSucceeded && snapshot.AnyTornadoProcess,
_ => false
};
private bool HasConnectedProcessGenerationChanged(TornadoProcessSnapshot snapshot) =>
_session is not null &&
(_connectedProcessSnapshot is null ||
!_connectedProcessSnapshot.HasSameProcessGeneration(snapshot));
private bool CanTakeIn() => _options.Mode switch
{
PlayoutMode.Test => true,
PlayoutMode.Live => IsLiveAuthorized(),
_ => false
};
private bool IsLiveAuthorized() =>
_options.TrustedLiveOutputEnabled &&
_liveAuthorization.IsAuthorizedForThisLaunch;
private PlayoutResult MarkOutcomeUnknown(PlayoutOperation operation)
{
_outcomeUnknown = true;
_connectionRequested = false;
_session = null;
_connectedProcessSnapshot = null;
_preparedCue = null;
_onAirSceneName = null;
_connectionState = PlayoutConnectionState.OutcomeUnknown;
PublishStatus("송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요.");
return ExistingOutcomeUnknown(operation);
}
private PlayoutResult ExistingOutcomeUnknown(PlayoutOperation operation) =>
Result(
operation,
PlayoutResultCode.OutcomeUnknown,
"송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요.");
private void ScheduleReconnect() =>
_nextReconnectAt = _timeProvider.GetUtcNow() + _options.ReconnectDelay;
private void PublishStatus(string message)
{
PlayoutStatus previous;
PlayoutStatus current;
lock (_statusSync)
{
previous = Status;
current = BuildStatus(message);
Status = current;
}
var handlers = StatusChanged;
if (handlers is null)
{
return;
}
foreach (EventHandler<PlayoutStatusChangedEventArgs> handler in handlers.GetInvocationList())
{
try
{
handler(this, new PlayoutStatusChangedEventArgs(previous, current));
}
catch (Exception)
{
// A UI subscriber cannot break the playout state machine.
}
}
}
private PlayoutStatus BuildStatus(string message)
{
var connected = _session is not null && !_outcomeUnknown;
var commandAvailable = _options.Mode == PlayoutMode.DryRun ||
(connected && IsProcessEligible(_processSnapshot));
var liveAllowed = connected &&
IsProcessEligible(_processSnapshot) &&
CanTakeIn();
return new PlayoutStatus(
_options.Mode,
_connectionState,
connected,
_processSnapshot.AnyTornadoProcess,
commandAvailable,
liveAllowed,
_preparedCue is null ? null : SafeSceneName(_preparedCue),
_onAirSceneName,
message,
_timeProvider.GetUtcNow(),
Interlocked.Increment(ref _sequence))
{
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds,
LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read(
ref _lastKtapConnectState),
KtapHelloObserved = null
};
}
private void CaptureKtapEvidence(IK3dSession? session)
{
if (session is null)
{
return;
}
var state = session.LastKtapConnectState;
if (state != PlayoutKtapConnectState.NotAttempted)
{
Volatile.Write(ref _lastKtapConnectState, (int)state);
}
}
private void PreventLateKtapDispatchAndCapture(IK3dSession? session)
{
if (session is null)
{
return;
}
session.TryPreventKtapConnect();
CaptureKtapEvidence(session);
}
private PlayoutResult Result(
PlayoutOperation operation,
PlayoutResultCode code,
string message) => new(
operation,
code,
_options.Mode == PlayoutMode.DryRun,
message,
_timeProvider.GetUtcNow());
private static string SafeSceneName(PlayoutCue cue)
{
var value = cue.SceneName?.Trim();
if (string.IsNullOrEmpty(value) || value.Length > 64)
{
return "장면";
}
foreach (var character in value)
{
if (!((character >= 'A' && character <= 'Z') ||
(character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9') ||
character is '_' or '-'))
{
return "장면";
}
}
return value;
}
private string CurrentMessage() => _connectionState switch
{
PlayoutConnectionState.Connected =>
"KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다.",
PlayoutConnectionState.Reconnecting => "Tornado 송출에 다시 연결하는 중입니다.",
PlayoutConnectionState.Faulted => "Tornado 송출 연결을 확인하세요.",
_ => InitialMessage(_options.Mode)
};
private static string InitialMessage(PlayoutMode mode) => mode switch
{
PlayoutMode.Disabled => "송출 기능이 비활성화되어 있습니다.",
PlayoutMode.DryRun => DryRunMonitoringMessage,
_ => "Tornado 송출 연결이 필요합니다."
};
private const string DryRunMonitoringMessage =
"DryRun: COM/KTAPConnect를 호출하지 않았습니다. Network Monitoring 기록 없음이 정상입니다.";
private static string DryRunOperationMessage(string operation) =>
$"DryRun: {operation} COM/KTAPConnect는 호출하지 않았으며 Network Monitoring 기록 없음이 정상입니다.";
private string ProcessUnavailableMessage(TornadoProcessSnapshot snapshot)
{
if (_options.Mode == PlayoutMode.Test)
{
if (snapshot.ProgramProcessCount > 0)
{
return "PROGRAM Tornado가 실행 중이므로 테스트 연결을 차단했습니다.";
}
if (snapshot.TotalProcessCount > 1)
{
return "Tornado 인스턴스가 여러 개여서 테스트 연결을 차단했습니다.";
}
return "허용된 테스트 Tornado 인스턴스를 찾을 수 없습니다.";
}
return "Tornado 프로세스를 찾을 수 없습니다.";
}
private static string SuccessMessage(PlayoutOperation operation) => operation switch
{
PlayoutOperation.Prepare => "장면이 준비되었습니다.",
PlayoutOperation.TakeIn => "장면이 송출되었습니다.",
PlayoutOperation.Next => "다음 장면이 송출되었습니다.",
PlayoutOperation.TakeOut => "장면 송출이 종료되었습니다.",
_ => "송출 명령이 완료되었습니다."
};
}