feat: migrate legacy playout workflow and scenes
This commit is contained in:
@@ -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 => "다음 장면이 송출되었습니다.",
|
||||
|
||||
Reference in New Issue
Block a user