feat: add safe Tornado K3D playout adapter

This commit is contained in:
2026-07-10 06:41:48 +09:00
parent 39c4504b87
commit 5a8c7028dc
43 changed files with 7341 additions and 92 deletions

503
MainWindow.Playout.cs Normal file
View 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);
}