1419 lines
48 KiB
C#
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 => "장면 송출이 종료되었습니다.",
|
|
_ => "송출 명령이 완료되었습니다."
|
|
};
|
|
}
|