feat: migrate legacy playout workflow and scenes

This commit is contained in:
2026-07-10 23:58:45 +09:00
parent 491a740505
commit 8dae7b8e0d
128 changed files with 39177 additions and 205 deletions

View File

@@ -37,6 +37,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private int _reconnectAttempts;
private long _sequence;
private int _lastKtapConnectState;
private int _ktapHelloObservedState;
private bool _connectionRequested;
private volatile bool _outcomeUnknown;
private volatile bool _quarantineIncomplete;
@@ -118,6 +119,17 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
token => NextCoreAsync(cue, token));
}
public Task<PlayoutResult> UpdateOnAirAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cue);
return SerializedAsync(
PlayoutOperation.UpdateOnAir,
cancellationToken,
token => UpdateOnAirCoreAsync(cue, token));
}
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default) =>
@@ -190,7 +202,18 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
if (_session is not null && _dispatcher is not null && !_dispatcher.IsQuarantined)
{
if (_onAirSceneName is not null || _outcomeUnknown)
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);
@@ -307,6 +330,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
: 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)
@@ -702,6 +732,26 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
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))
@@ -749,9 +799,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, exception.Message));
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved))
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(resolved))
{
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다."));
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다."));
}
return PrepareActualAsync(resolved, cancellationToken);
@@ -810,9 +861,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다.");
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(_preparedCue))
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(_preparedCue))
{
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.");
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다.");
}
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.TakeIn, cancellationToken)
@@ -879,9 +931,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, exception.Message);
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved))
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(resolved))
{
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.");
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다.");
}
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.Next, cancellationToken)
@@ -914,6 +967,108 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return result;
}
private async Task<PlayoutResult> 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<PlayoutResult> TakeOutCoreAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken)
@@ -960,7 +1115,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
_preparedCue = null;
_onAirSceneName = null;
PublishStatus("장면 송출이 종료되었습니다.");
PublishStatus(_session?.HasPendingLifecycleCallbacks == true
? "TAKE OUT 명령이 수락되어 Tornado 완료 이벤트를 기다리고 있습니다."
: "장면 송출이 종료되었습니다.");
}
return result;
@@ -1042,9 +1199,76 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
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<bool> 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<PlayoutResult> RunSessionOperationAsync(
PlayoutOperation operation,
Action<IK3dSession, SdkDispatchLatch> callback,
@@ -1177,6 +1401,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
}
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)
@@ -1599,7 +1831,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds,
LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read(
ref _lastKtapConnectState),
KtapHelloObserved = null
KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch
{
1 => false,
2 => true,
_ => null
}
};
}
@@ -1615,6 +1852,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
Volatile.Write(ref _lastKtapConnectState, (int)state);
}
if (session.SupportsLifecycleCallbacks)
{
Volatile.Write(
ref _ktapHelloObservedState,
session.KtapHelloObserved == true ? 2 : 1);
}
}
private void PreventLateKtapDispatchAndCapture(IK3dSession? session)
@@ -1660,6 +1904,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
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 =>
@@ -1704,6 +1955,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private static string SuccessMessage(PlayoutOperation operation) => operation switch
{
PlayoutOperation.UpdateOnAir => "The on-air scene page was updated.",
PlayoutOperation.Prepare => "장면이 준비되었습니다.",
PlayoutOperation.TakeIn => "장면이 송출되었습니다.",
PlayoutOperation.Next => "다음 장면이 송출되었습니다.",