feat: add safe Tornado K3D playout adapter
This commit is contained in:
503
MainWindow.Playout.cs
Normal file
503
MainWindow.Playout.cs
Normal file
@@ -0,0 +1,503 @@
|
||||
using System.Text.Json;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW;
|
||||
|
||||
public sealed partial class MainWindow
|
||||
{
|
||||
private const int MaximumPlayoutRequestIdLength = 128;
|
||||
private const int MaximumPlayoutCodeLength = 64;
|
||||
private const int MaximumPlayoutTitleLength = 200;
|
||||
private const int MaximumPlayoutDetailLength = 500;
|
||||
|
||||
private readonly SemaphoreSlim _playoutCommandGate = new(1, 1);
|
||||
private IPlayoutEngine? _playoutEngine;
|
||||
private string? _playoutInitializationError;
|
||||
private int _playoutCommandInFlight;
|
||||
private int _playoutShutdownStarted;
|
||||
|
||||
private void InitializePlayoutRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
_playoutEngine = PlayoutEngineFactory.CreateDefault();
|
||||
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_playoutInitializationError = exception is PlayoutConfigurationException
|
||||
? exception.Message
|
||||
: "Tornado 송출 어댑터를 초기화하지 못했습니다.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectPlayoutAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var engine = _playoutEngine;
|
||||
if (engine is null || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
QueuePlayoutStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await engine.ConnectAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다.";
|
||||
}
|
||||
|
||||
QueuePlayoutStatus();
|
||||
}
|
||||
|
||||
private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
|
||||
{
|
||||
if (Volatile.Read(ref _playoutCommandInFlight) != 0 ||
|
||||
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QueuePlayoutStatus();
|
||||
}
|
||||
|
||||
private void QueuePlayoutStatus()
|
||||
{
|
||||
if (DispatcherQueue.HasThreadAccess)
|
||||
{
|
||||
PostPlayoutStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
DispatcherQueue.TryEnqueue(() => PostPlayoutStatus());
|
||||
}
|
||||
|
||||
private void HandlePlayoutStatusRequest(JsonElement payload)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "requestId") ||
|
||||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId))
|
||||
{
|
||||
PostMessage("bridge-error", new { message = "올바르지 않은 송출 상태 요청입니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
PostPlayoutStatus(requestId);
|
||||
}
|
||||
|
||||
private async Task HandlePlayoutCommandAsync(JsonElement payload)
|
||||
{
|
||||
var parsed = TryParsePlayoutCommand(payload, out var request, out var validationError);
|
||||
if (!parsed)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"INVALID_REQUEST",
|
||||
validationError,
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var engine = _playoutEngine;
|
||||
if (engine is null)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"PLAYOUT_UNAVAILABLE",
|
||||
_playoutInitializationError ?? "Tornado 송출 어댑터를 사용할 수 없습니다.",
|
||||
retryable: false,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await _playoutCommandGate.WaitAsync(0))
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"PLAYOUT_BUSY",
|
||||
"다른 송출 명령을 처리하고 있습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
||||
var result = await ExecutePlayoutCommandAsync(engine, request, _lifetimeCancellation.Token);
|
||||
var status = engine.Status;
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
ToWireValue(result.Code),
|
||||
result.Message,
|
||||
IsRetryable(result.Code),
|
||||
result.Code == PlayoutResultCode.OutcomeUnknown);
|
||||
return;
|
||||
}
|
||||
|
||||
PostMessage("playout-command-result", new
|
||||
{
|
||||
requestId = request.RequestId,
|
||||
command = request.Command,
|
||||
succeeded = true,
|
||||
dryRun = result.IsDryRun,
|
||||
state = ToPlayoutPhase(status),
|
||||
message = result.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"CANCELLED",
|
||||
"송출 요청이 취소되었습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"PLAYOUT_FAILED",
|
||||
"송출 엔진 명령을 완료하지 못했습니다.",
|
||||
retryable: true,
|
||||
outcomeUnknown: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutCommandInFlight, 0);
|
||||
_playoutCommandGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<PlayoutResult> ExecutePlayoutCommandAsync(
|
||||
IPlayoutEngine engine,
|
||||
ValidatedPlayoutCommand request,
|
||||
CancellationToken cancellationToken) => request.Command switch
|
||||
{
|
||||
"prepare" => engine.PrepareAsync(request.Cue!, cancellationToken),
|
||||
"take-in" => engine.TakeInAsync(cancellationToken),
|
||||
"next" => engine.NextAsync(request.Cue!, cancellationToken),
|
||||
// Preserve the legacy MainForm TAKE OUT behavior (StopAll on the selected
|
||||
// scene player). Test/Live safety gates are enforced inside the engine.
|
||||
"take-out" => engine.TakeOutAsync(PlayoutTakeOutScope.All, cancellationToken),
|
||||
_ => throw new InvalidOperationException("검증되지 않은 송출 명령입니다.")
|
||||
};
|
||||
|
||||
private void PostPlayoutStatus(string? requestId = null)
|
||||
{
|
||||
var status = _playoutEngine?.Status;
|
||||
if (status is null)
|
||||
{
|
||||
var changedAt = DateTimeOffset.UtcNow;
|
||||
if (requestId is null)
|
||||
{
|
||||
PostMessage("playout-status", new
|
||||
{
|
||||
mode = "disabled",
|
||||
state = "IDLE",
|
||||
processDetected = false,
|
||||
connected = false,
|
||||
liveTakeInAllowed = false,
|
||||
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
||||
preparedCode = (string?)null,
|
||||
onAirCode = (string?)null,
|
||||
changedAt
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
PostMessage("playout-status", new
|
||||
{
|
||||
requestId,
|
||||
mode = "disabled",
|
||||
state = "IDLE",
|
||||
processDetected = false,
|
||||
connected = false,
|
||||
liveTakeInAllowed = false,
|
||||
message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.",
|
||||
preparedCode = (string?)null,
|
||||
onAirCode = (string?)null,
|
||||
changedAt
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestId is null)
|
||||
{
|
||||
PostMessage("playout-status", CreatePlayoutStatusPayload(status));
|
||||
}
|
||||
else
|
||||
{
|
||||
PostMessage("playout-status", new
|
||||
{
|
||||
requestId,
|
||||
mode = ToWireValue(status.Mode),
|
||||
state = ToPlayoutPhase(status),
|
||||
processDetected = status.IsProcessRunning,
|
||||
connected = status.IsConnected,
|
||||
liveTakeInAllowed = status.LiveTakeInAllowed,
|
||||
message = status.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName,
|
||||
changedAt = status.ChangedAtUtc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static object CreatePlayoutStatusPayload(PlayoutStatus status) => new
|
||||
{
|
||||
mode = ToWireValue(status.Mode),
|
||||
state = ToPlayoutPhase(status),
|
||||
processDetected = status.IsProcessRunning,
|
||||
connected = status.IsConnected,
|
||||
liveTakeInAllowed = status.LiveTakeInAllowed,
|
||||
message = status.Message,
|
||||
preparedCode = status.PreparedSceneName,
|
||||
onAirCode = status.OnAirSceneName,
|
||||
changedAt = status.ChangedAtUtc
|
||||
};
|
||||
|
||||
private void PostPlayoutCommandError(
|
||||
string requestId,
|
||||
string command,
|
||||
string code,
|
||||
string message,
|
||||
bool retryable,
|
||||
bool outcomeUnknown)
|
||||
{
|
||||
PostMessage("playout-command-error", new
|
||||
{
|
||||
requestId,
|
||||
command,
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
outcomeUnknown
|
||||
});
|
||||
}
|
||||
|
||||
private static bool TryParsePlayoutCommand(
|
||||
JsonElement payload,
|
||||
out ValidatedPlayoutCommand request,
|
||||
out string error)
|
||||
{
|
||||
var requestId = payload.ValueKind == JsonValueKind.Object
|
||||
? GetString(payload, "requestId") ?? string.Empty
|
||||
: string.Empty;
|
||||
var command = payload.ValueKind == JsonValueKind.Object
|
||||
? GetString(payload, "command") ?? string.Empty
|
||||
: string.Empty;
|
||||
request = new ValidatedPlayoutCommand(requestId, command, null);
|
||||
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "requestId", "command", "cue"))
|
||||
{
|
||||
error = "송출 요청에 허용되지 않은 필드가 있습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId))
|
||||
{
|
||||
error = "송출 요청 식별자가 올바르지 않습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (command is not ("prepare" or "take-in" or "next" or "take-out"))
|
||||
{
|
||||
error = "지원하지 않는 송출 명령입니다.";
|
||||
request = new ValidatedPlayoutCommand(requestId, command, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new ValidatedPlayoutCommand(requestId, command, null);
|
||||
var cueRequired = command is not "take-out";
|
||||
if (!payload.TryGetProperty("cue", out var cueElement))
|
||||
{
|
||||
if (!cueRequired)
|
||||
{
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "이 송출 명령에는 그래픽 cue가 필요합니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParsePlayoutCue(cueElement, out var cue, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new ValidatedPlayoutCommand(requestId, command, cue);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParsePlayoutCue(
|
||||
JsonElement payload,
|
||||
out PlayoutCue? cue,
|
||||
out string error)
|
||||
{
|
||||
cue = null;
|
||||
if (payload.ValueKind != JsonValueKind.Object ||
|
||||
!HasOnlyProperties(payload, "code", "title", "detail") ||
|
||||
!TryGetSafeToken(payload, "code", MaximumPlayoutCodeLength, out var code) ||
|
||||
!TryGetBoundedText(payload, "title", MaximumPlayoutTitleLength, allowEmpty: false, out _) ||
|
||||
!TryGetBoundedText(payload, "detail", MaximumPlayoutDetailLength, allowEmpty: true, out _))
|
||||
{
|
||||
error = "그래픽 cue 형식이 올바르지 않습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var sceneFile = $"{code}.t2s";
|
||||
if (!string.Equals(Path.GetFileName(sceneFile), sceneFile, StringComparison.Ordinal) ||
|
||||
sceneFile.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
error = "그래픽 cue 경로가 올바르지 않습니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
cue = new PlayoutCue(sceneFile, code);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetSafeToken(
|
||||
JsonElement payload,
|
||||
string propertyName,
|
||||
int maximumLength,
|
||||
out string value)
|
||||
{
|
||||
value = GetString(payload, propertyName) ?? string.Empty;
|
||||
return value.Length is > 0 && value.Length <= maximumLength &&
|
||||
value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-');
|
||||
}
|
||||
|
||||
private static bool TryGetBoundedText(
|
||||
JsonElement payload,
|
||||
string propertyName,
|
||||
int maximumLength,
|
||||
bool allowEmpty,
|
||||
out string value)
|
||||
{
|
||||
value = GetString(payload, propertyName) ?? string.Empty;
|
||||
return value.Length <= maximumLength &&
|
||||
(allowEmpty || value.Length > 0) &&
|
||||
!value.Any(char.IsControl);
|
||||
}
|
||||
|
||||
private static bool HasOnlyProperties(JsonElement payload, params string[] allowed)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var property in payload.EnumerateObject())
|
||||
{
|
||||
if (!seen.Add(property.Name) ||
|
||||
!allowed.Contains(property.Name, StringComparer.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ToPlayoutPhase(PlayoutStatus status)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(status.OnAirSceneName))
|
||||
{
|
||||
return "PROGRAM";
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(status.PreparedSceneName) ? "PREPARED" : "IDLE";
|
||||
}
|
||||
|
||||
private static string ToWireValue(PlayoutMode mode) => mode switch
|
||||
{
|
||||
PlayoutMode.Disabled => "disabled",
|
||||
PlayoutMode.DryRun => "dry-run",
|
||||
PlayoutMode.Test => "test",
|
||||
PlayoutMode.Live => "live",
|
||||
_ => "disabled"
|
||||
};
|
||||
|
||||
private static string ToWireValue(PlayoutResultCode code) => code switch
|
||||
{
|
||||
PlayoutResultCode.Rejected => "REJECTED",
|
||||
PlayoutResultCode.Cancelled => "CANCELLED",
|
||||
PlayoutResultCode.TimedOut => "TIMED_OUT",
|
||||
PlayoutResultCode.Unavailable => "UNAVAILABLE",
|
||||
PlayoutResultCode.OutcomeUnknown => "OUTCOME_UNKNOWN",
|
||||
_ => "PLAYOUT_FAILED"
|
||||
};
|
||||
|
||||
private static bool IsRetryable(PlayoutResultCode code) => code is
|
||||
PlayoutResultCode.Cancelled or
|
||||
PlayoutResultCode.TimedOut or
|
||||
PlayoutResultCode.Unavailable or
|
||||
PlayoutResultCode.Failed or
|
||||
PlayoutResultCode.OutcomeUnknown;
|
||||
|
||||
private void ShutdownPlayoutRuntime()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var engine = Interlocked.Exchange(ref _playoutEngine, null);
|
||||
if (engine is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
engine.StatusChanged -= OnPlayoutStatusChanged;
|
||||
using var disconnectCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
try
|
||||
{
|
||||
var disconnect = engine.DisconnectAsync(disconnectCancellation.Token);
|
||||
if (!disconnect.Wait(TimeSpan.FromSeconds(4)))
|
||||
{
|
||||
disconnectCancellation.Cancel();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// App shutdown continues even when the external playout process is unavailable.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
engine.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal is best-effort and bounded during window shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ValidatedPlayoutCommand(
|
||||
string RequestId,
|
||||
string Command,
|
||||
PlayoutCue? Cue);
|
||||
}
|
||||
Reference in New Issue
Block a user