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 Func? _finalConnectSafetyCheck; private readonly bool _abandonOnTargetMismatch; private readonly Action? _beforeSdkDispatchClaim; 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 int _ktapHelloObservedState; private bool _connectionRequested; private volatile bool _outcomeUnknown; private volatile bool _quarantineIncomplete; 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, Func? finalConnectSafetyCheck = null, bool abandonOnTargetMismatch = false, Action? beforeSdkDispatchClaim = null) { _options = options; _sessionFactory = sessionFactory; _registrationProbe = registrationProbe; _processProbe = processProbe; _dispatcher = dispatcher; _liveAuthorization = liveAuthorization; _timeProvider = timeProvider; _finalConnectSafetyCheck = finalConnectSafetyCheck; _abandonOnTargetMismatch = abandonOnTargetMismatch; _beforeSdkDispatchClaim = beforeSdkDispatchClaim; _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? StatusChanged; public Task ConnectAsync(CancellationToken cancellationToken = default) => SerializedAsync(PlayoutOperation.Connect, cancellationToken, ConnectCoreAsync); public Task DisconnectAsync(CancellationToken cancellationToken = default) => SerializedAsync(PlayoutOperation.Disconnect, cancellationToken, DisconnectCoreAsync); public Task PrepareAsync( PlayoutCue cue, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(cue); return SerializedAsync( PlayoutOperation.Prepare, cancellationToken, token => PrepareCoreAsync(cue, token)); } public Task TakeInAsync(CancellationToken cancellationToken = default) => SerializedAsync(PlayoutOperation.TakeIn, cancellationToken, TakeInCoreAsync); public Task NextAsync( PlayoutCue cue, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(cue); return SerializedAsync( PlayoutOperation.Next, cancellationToken, token => NextCoreAsync(cue, token)); } public Task UpdateOnAirAsync( PlayoutCue cue, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(cue); return SerializedAsync( PlayoutOperation.UpdateOnAir, cancellationToken, token => UpdateOnAirCoreAsync(cue, token)); } public Task 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; var quarantineCompleted = !_quarantineIncomplete; if (session is not null) { quarantineCompleted = await AbandonSessionOnStaAsync(session).ConfigureAwait(false) && quarantineCompleted; } if (!quarantineCompleted || _quarantineIncomplete) { throw new InvalidOperationException( "The playout session could not be quarantined."); } 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 (_session.HasPendingLifecycleCallbacks && !await ProcessLifecycleCallbacksAsync(CancellationToken.None).ConfigureAwait(false)) { _outcomeUnknown = true; } if (_session?.HasPendingLifecycleCallbacks == true) { _outcomeUnknown = true; await AbandonCurrentSessionAsync().ConfigureAwait(false); } else 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 && !await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false)) { MarkOutcomeUnknown(PlayoutOperation.Disconnect); return; } 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 SerializedAsync( PlayoutOperation operation, CancellationToken cancellationToken, Func> 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 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 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; var dispatchLatch = new SdkDispatchLatch(_beforeSdkDispatchClaim); try { var session = _sessionFactory.Create(); Volatile.Write(ref pendingSession, session); _session = await _dispatcher.InvokeAsync( () => { try { if (_finalConnectSafetyCheck is null) { session.Connect(_options); } else if (session is IK3dGuardedConnectSession guardedSession) { guardedSession.ConnectGuarded( _options, IsExactTargetSafeForSdkDispatch, dispatchLatch); } else { throw new K3dConnectSafetyException(); } return session; } catch { if (_abandonOnTargetMismatch) { AbandonSessionInline(session); } else { session.Dispose(); } throw; } }, _options.ConnectTimeout, cancellationToken, lateSession => { if (_abandonOnTargetMismatch) { AbandonSessionInline(lateSession); } else { lateSession.Dispose(); } }, () => Volatile.Read(ref pendingSession)?.TryPreventKtapConnect(), dispatchLatch.PreventFutureDispatch).ConfigureAwait(false); CaptureKtapEvidence(_session); _processSnapshot = CaptureProcessSnapshot(); if (!IsProcessEligible(_processSnapshot) || !processSnapshotBeforeConnect.HasSameProcessGeneration(_processSnapshot)) { if (_abandonOnTargetMismatch) { await AbandonCurrentSessionAsync().ConfigureAwait(false); return MarkOutcomeUnknown(PlayoutOperation.Connect); } 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) { var cancelledSession = Volatile.Read(ref pendingSession); PreventLateKtapDispatchAndCapture(cancelledSession); if (_abandonOnTargetMismatch && cancelledSession is not null && cancelledSession.LastKtapConnectState != PlayoutKtapConnectState.NotAttempted) { _session = null; return MarkOutcomeUnknown(PlayoutOperation.Connect); } _connectionState = PlayoutConnectionState.Disconnected; PublishStatus("송출 연결 요청이 취소되었습니다."); return Result(PlayoutOperation.Connect, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다."); } catch (StaOperationTimedOutException) { if (_abandonOnTargetMismatch) { _quarantineIncomplete = true; } PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession)); return MarkOutcomeUnknown(PlayoutOperation.Connect); } catch (StaDispatcherQuarantinedException) { if (_abandonOnTargetMismatch) { _quarantineIncomplete = true; } PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession)); return MarkOutcomeUnknown(PlayoutOperation.Connect); } catch (Exception) { var failedSession = Volatile.Read(ref pendingSession); CaptureKtapEvidence(failedSession); _session = null; if (_abandonOnTargetMismatch && failedSession is not null && failedSession.LastKtapConnectState != PlayoutKtapConnectState.NotAttempted) { return MarkOutcomeUnknown(PlayoutOperation.Connect); } ScheduleReconnect(); _connectionState = reconnecting ? PlayoutConnectionState.Reconnecting : PlayoutConnectionState.Faulted; PublishStatus("Tornado 송출에 연결할 수 없습니다."); return Result(PlayoutOperation.Connect, PlayoutResultCode.Failed, "Tornado 송출에 연결할 수 없습니다."); } } private async Task 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); } if (_session?.HasPendingLifecycleCallbacks == true) { if (!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false)) { return MarkOutcomeUnknown(PlayoutOperation.Disconnect); } if (_session?.HasPendingLifecycleCallbacks == true) { _connectionState = PlayoutConnectionState.Connected; const string pendingMessage = "Tornado 장면 완료 이벤트를 기다리는 중이므로 연결을 해제하지 않았습니다."; PublishStatus(pendingMessage); return Result( PlayoutOperation.Disconnect, PlayoutResultCode.Unavailable, pendingMessage); } } _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 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 is PlayoutMode.Test or PlayoutMode.Live && !_options.IsOutputSceneAllowed(resolved)) { return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다.")); } return PrepareActualAsync(resolved, cancellationToken); } private async Task 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, dispatchLatch) => ExecuteSdkDispatch( dispatchLatch, () => session.Prepare(cue, _options.LayoutIndex)), partialOutcomeUnknown: false, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { _preparedCue = cue; PublishStatus("장면이 준비되었습니다."); } return result; } private async Task 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 is PlayoutMode.Test or PlayoutMode.Live && !_options.IsOutputSceneAllowed(_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, dispatchLatch) => ExecuteSdkDispatch( dispatchLatch, () => session.Play(_options.LayoutIndex)), partialOutcomeUnknown: true, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { _onAirSceneName = SafeSceneName(prepared); _preparedCue = null; PublishStatus("장면이 송출되었습니다."); } return result; } private async Task 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 is PlayoutMode.Test or PlayoutMode.Live && !_options.IsOutputSceneAllowed(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, dispatchLatch) => { ExecuteSdkDispatch( dispatchLatch, () => session.Prepare(resolved, _options.LayoutIndex)); ExecuteSdkDispatch( dispatchLatch, () => session.Play(_options.LayoutIndex)); }, partialOutcomeUnknown: true, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { _onAirSceneName = SafeSceneName(resolved); _preparedCue = null; PublishStatus("다음 장면이 송출되었습니다."); } return result; } private async Task UpdateOnAirCoreAsync( PlayoutCue cue, CancellationToken cancellationToken) { if (_onAirSceneName is null) { return Result( PlayoutOperation.UpdateOnAir, PlayoutResultCode.Rejected, "Same-scene refresh requires a scene that is already on air."); } if (_options.Mode == PlayoutMode.Disabled) { return Result( PlayoutOperation.UpdateOnAir, PlayoutResultCode.Rejected, "Playout is disabled."); } if (_options.Mode == PlayoutMode.DryRun) { if (!IsCurrentOnAirCue(cue)) { return Result( PlayoutOperation.UpdateOnAir, PlayoutResultCode.Rejected, "Same-scene refresh can update only the scene currently on air."); } _preparedCue = null; var message = DryRunOperationMessage("Same-scene refresh was validated."); PublishStatus(message); return Result( PlayoutOperation.UpdateOnAir, PlayoutResultCode.Success, message); } if (!CanTakeIn()) { return Result( PlayoutOperation.UpdateOnAir, PlayoutResultCode.Rejected, "Live playout authorization is required."); } PlayoutCue resolved; try { resolved = _options.ResolveCue(cue); } catch (PlayoutRequestException exception) { return Result( PlayoutOperation.UpdateOnAir, PlayoutResultCode.Rejected, exception.Message); } if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live && !_options.IsOutputSceneAllowed(resolved)) { return Result( PlayoutOperation.UpdateOnAir, PlayoutResultCode.Rejected, "The scene is not approved for output."); } if (!IsCurrentOnAirCue(resolved)) { return Result( PlayoutOperation.UpdateOnAir, PlayoutResultCode.Rejected, "Same-scene refresh can update only the scene currently on air."); } var availability = await EnsureCommandAvailableAsync( PlayoutOperation.UpdateOnAir, cancellationToken).ConfigureAwait(false); if (availability is not null) { return availability; } var result = await RunSessionOperationAsync( PlayoutOperation.UpdateOnAir, (session, dispatchLatch) => ExecuteSdkDispatch( dispatchLatch, () => session.UpdateOnAir(resolved, _options.LayoutIndex)), partialOutcomeUnknown: true, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { _onAirSceneName = SafeSceneName(resolved); _preparedCue = null; PublishStatus("The on-air scene page was updated."); } return result; } private async Task 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, dispatchLatch) => ExecuteSdkDispatch( dispatchLatch, () => session.TakeOut(_options.LayoutIndex, scope)), partialOutcomeUnknown: true, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { _preparedCue = null; _onAirSceneName = null; PublishStatus(_session?.HasPendingLifecycleCallbacks == true ? "TAKE OUT 명령이 수락되어 Tornado 완료 이벤트를 기다리고 있습니다." : "장면 송출이 종료되었습니다."); } return result; } private async Task EnsureCommandAvailableAsync( PlayoutOperation operation, CancellationToken cancellationToken) { if (_outcomeUnknown || _dispatcher?.IsQuarantined == true) { return MarkOutcomeUnknown(operation); } _processSnapshot = CaptureProcessSnapshot(); if (!IsProcessEligible(_processSnapshot)) { if (_abandonOnTargetMismatch && _session is not null) { await AbandonCurrentSessionAsync().ConfigureAwait(false); return MarkOutcomeUnknown(operation); } 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 (_abandonOnTargetMismatch) { await AbandonCurrentSessionAsync().ConfigureAwait(false); return MarkOutcomeUnknown(operation); } 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 송출 연결이 필요합니다."); } if (!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false)) { return MarkOutcomeUnknown(operation); } if (RequiresCompletedPlay(operation) && _session?.HasPendingPlayCallbacks == true) { const string pendingPlayMessage = "The previous SDK Play completion callback is still pending. " + "Only TAKE OUT is allowed until Tornado confirms the result."; PublishStatus(pendingPlayMessage); return Result( operation, PlayoutResultCode.Unavailable, pendingPlayMessage); } return null; } private static bool RequiresCompletedPlay(PlayoutOperation operation) => operation is PlayoutOperation.Prepare or PlayoutOperation.TakeIn or PlayoutOperation.Next or PlayoutOperation.UpdateOnAir; private async Task ProcessLifecycleCallbacksAsync( CancellationToken cancellationToken) { var session = _session; if (session is null || !session.SupportsLifecycleCallbacks) { CaptureKtapEvidence(session); return true; } if (_dispatcher is null || _dispatcher.IsQuarantined) { return false; } try { await _dispatcher.InvokeAsync( () => session.ProcessPendingCallbacks(_options.LayoutIndex), _options.OperationTimeout, CancellationToken.None, _ => AbandonSessionInline(session), () => AbandonSessionInline(session)).ConfigureAwait(false); CaptureKtapEvidence(session); return true; } catch { _session = null; _connectedProcessSnapshot = null; if (_dispatcher.IsQuarantined) { _quarantineIncomplete = true; } else if (!await AbandonSessionOnStaAsync(session).ConfigureAwait(false)) { _quarantineIncomplete = true; } return false; } } private async Task RunSessionOperationAsync( PlayoutOperation operation, Action callback, bool partialOutcomeUnknown, CancellationToken cancellationToken) { var session = _session!; var dispatchLatch = new SdkDispatchLatch(_beforeSdkDispatchClaim); var callbackStarted = 0; var preserveActiveOutput = _abandonOnTargetMismatch || partialOutcomeUnknown || _onAirSceneName is not null; void CleanupAbandonedOperation() { if (preserveActiveOutput) { AbandonSessionInline(session); } else { session.Dispose(); } } try { await _dispatcher!.InvokeAsync( () => { Volatile.Write(ref callbackStarted, 1); try { callback(session, dispatchLatch); } finally { if (_dispatcher.IsQuarantined) { CleanupAbandonedOperation(); } } return true; }, _options.OperationTimeout, cancellationToken, _ => CleanupAbandonedOperation(), CleanupAbandonedOperation, dispatchLatch.PreventFutureDispatch).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) { if (preserveActiveOutput) { _quarantineIncomplete = true; } return MarkOutcomeUnknown(operation); } catch (StaDispatcherQuarantinedException) { if (preserveActiveOutput) { _quarantineIncomplete = true; } 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 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 ReleaseSessionAsync(CancellationToken cancellationToken) { if (_onAirSceneName is not null) { await AbandonCurrentSessionAsync().ConfigureAwait(false); return false; } var session = _session; if (session?.HasPendingLifecycleCallbacks == true) { // A late OnScenePlayed/OnCutOut/OnStopAll callback still owns scene lifetime. // Never issue Disconnect while that result is unresolved. await AbandonCurrentSessionAsync().ConfigureAwait(false); return false; } _session = null; _connectedProcessSnapshot = null; if (session is null) { return true; } if (_dispatcher is null || _dispatcher.IsQuarantined) { if (_abandonOnTargetMismatch) { _quarantineIncomplete = true; } return false; } try { await CleanupSessionOnStaAsync(session, cancellationToken).ConfigureAwait(false); return true; } catch (OperationCanceledException) { if (_dispatcher.IsQuarantined) { if (_abandonOnTargetMismatch) { _quarantineIncomplete = true; } return false; } try { await CleanupSessionOnStaAsync(session, CancellationToken.None).ConfigureAwait(false); return true; } catch (Exception) { if (_abandonOnTargetMismatch) { _quarantineIncomplete = true; } return false; } } catch (Exception) { if (_abandonOnTargetMismatch) { _quarantineIncomplete = true; } return false; } } private async Task AbandonSessionOnStaAsync(IK3dSession session) { if (_dispatcher is null || _dispatcher.IsQuarantined) { _quarantineIncomplete = true; return false; } try { await _dispatcher.InvokeAsync( () => { AbandonSessionInline(session); return true; }, _options.DisconnectTimeout, CancellationToken.None, _ => AbandonSessionInline(session), () => AbandonSessionInline(session)).ConfigureAwait(false); return true; } catch { // Never fall back to Disconnect when the physical output state is unknown. _quarantineIncomplete = true; 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 CleanupSessionOnStaAsync( IK3dSession session, CancellationToken cancellationToken) { var dispatchLatch = new SdkDispatchLatch(_beforeSdkDispatchClaim); return _dispatcher!.InvokeAsync( () => { if (_abandonOnTargetMismatch) { var disconnected = false; try { ExecuteSdkDispatch(dispatchLatch, session.Disconnect); disconnected = true; } catch { AbandonSessionInline(session); throw; } finally { if (disconnected) { session.Dispose(); } } return true; } try { ExecuteSdkDispatch(dispatchLatch, session.Disconnect); } finally { session.Dispose(); } return true; }, _options.DisconnectTimeout, cancellationToken, preventFutureDispatch: dispatchLatch.PreventFutureDispatch); } private void EnsureExactTarget() { if (_finalConnectSafetyCheck is not null && !IsExactTargetSafeForSdkDispatch()) { throw new K3dConnectSafetyException(); } } private void ExecuteSdkDispatch( SdkDispatchLatch dispatchLatch, Action sdkCall) { EnsureExactTarget(); using var dispatchClaim = dispatchLatch.TryClaim(); if (dispatchClaim is null) { throw new K3dConnectSafetyException(); } sdkCall(); EnsureExactTarget(); } private bool IsExactTargetSafeForSdkDispatch() { var dispatcher = _dispatcher; if (dispatcher is null || dispatcher.IsQuarantined) { return false; } try { return _finalConnectSafetyCheck is not null && _finalConnectSafetyCheck() && !dispatcher.IsQuarantined; } catch { return false; } } private void AbandonSessionInline(IK3dSession session) { try { session.Abandon(); } catch { _quarantineIncomplete = true; throw; } } private sealed class SdkDispatchLatch(Action? beforeClaim) : IK3dSdkDispatchLatch { private const int Open = 0; private const int Claimed = 1; private const int Prevented = 2; private const int ClaimedAndPrevented = 3; private int _state; public IDisposable? TryClaim() { beforeClaim?.Invoke(); return Interlocked.CompareExchange(ref _state, Claimed, Open) == Open ? new DispatchClaim(this) : null; } public void PreventFutureDispatch() { while (true) { var state = Volatile.Read(ref _state); var next = state switch { Open => Prevented, Claimed => ClaimedAndPrevented, _ => state }; if (next == state || Interlocked.CompareExchange(ref _state, next, state) == state) { return; } } } private void Release() { while (true) { var state = Volatile.Read(ref _state); var next = state switch { Claimed => Open, ClaimedAndPrevented => Prevented, _ => state }; if (next == state || Interlocked.CompareExchange(ref _state, next, state) == state) { return; } } } private sealed class DispatchClaim(SdkDispatchLatch owner) : IDisposable { private SdkDispatchLatch? _owner = owner; public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Release(); } } 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 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, IsPlayCompletionPending = _session?.HasPendingPlayCallbacks == true, LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read( ref _lastKtapConnectState), KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch { 1 => false, 2 => true, _ => 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); } if (session.SupportsLifecycleCallbacks) { Volatile.Write( ref _ktapHelloObservedState, session.KtapHelloObserved == true ? 2 : 1); } } 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 bool IsCurrentOnAirCue(PlayoutCue cue) { var sceneName = cue.SceneName?.Trim(); return !string.IsNullOrEmpty(sceneName) && string.Equals(sceneName, _onAirSceneName, StringComparison.OrdinalIgnoreCase); } 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.UpdateOnAir => "The on-air scene page was updated.", PlayoutOperation.Prepare => "장면이 준비되었습니다.", PlayoutOperation.TakeIn => "장면이 송출되었습니다.", PlayoutOperation.Next => "다음 장면이 송출되었습니다.", PlayoutOperation.TakeOut => "장면 송출이 종료되었습니다.", _ => "송출 명령이 완료되었습니다." }; }