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

12
.gitignore vendored
View File

@@ -26,11 +26,23 @@ Config/appsettings.local.json
Config/*.secrets.json
Config/database.local.json
**/database.local.json
Config/playout.local.json
**/playout.local.json
*.local.ini
.env
.env.*
!.env.example
# Tornado/K3D vendor artifacts (generated/installed locally only)
artifacts/K3DInterop/
**/Interop.K3DAsyncEngineLib.dll
**/K3DAsyncEngine.dll
**/K3DAsyncEngine.tlb
*.t2s
*.k3s
K3D*.lic
Tornado*.lic
# Web tooling (if introduced later)
node_modules/
dist/

View File

@@ -0,0 +1,21 @@
{
"mode": "DryRun",
"host": "127.0.0.1",
"port": 30001,
"tcpMode": 1,
"clientPort": 0,
"sceneDirectory": null,
"outputChannel": null,
"layoutIndex": 10,
"testProcessWindowTitlePattern": null,
"testSceneAllowlist": [],
"trustedLiveOutputEnabled": false,
"queueCapacity": 64,
"connectTimeoutMilliseconds": 5000,
"operationTimeoutMilliseconds": 5000,
"disconnectTimeoutMilliseconds": 3000,
"processPollIntervalMilliseconds": 1000,
"reconnectDelayMilliseconds": 1000,
"maximumReconnectAttempts": 3,
"reconnectEnabled": true
}

View File

@@ -42,6 +42,10 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="Config\appsettings.example.json" />
<Content Include="Config\playout.example.json" Condition="Exists('Config\playout.example.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="ThirdPartyNotices\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
@@ -72,6 +76,7 @@
<ItemGroup>
<ProjectReference Include="src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
<ProjectReference Include="src\MBN_STOCK_WEBVIEW.Infrastructure\MBN_STOCK_WEBVIEW.Infrastructure.csproj" />
<ProjectReference Include="src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
</ItemGroup>
<!--

View File

@@ -1,4 +1,3 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.33103.201
@@ -21,90 +20,56 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.DbSmoke",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Infrastructure.Tests", "tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests\MBN_STOCK_WEBVIEW.Infrastructure.Tests.csproj", "{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Playout", "src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj", "{8275871E-4ECB-465B-AA77-D09E61CC1D84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.Playout.Tests", "tests\MBN_STOCK_WEBVIEW.Playout.Tests\MBN_STOCK_WEBVIEW.Playout.Tests.csproj", "{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.PlayoutSmoke", "tools\MBN_STOCK_WEBVIEW.PlayoutSmoke\MBN_STOCK_WEBVIEW.PlayoutSmoke.csproj", "{515E56D2-8F90-45A0-83BC-44BD541B10A6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Debug|Any CPU = Debug|Any CPU
Debug|x86 = Debug|x86
Release|x64 = Release|x64
Release|Any CPU = Release|Any CPU
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.ActiveCfg = Debug|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.Build.0 = Debug|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x64.Deploy.0 = Debug|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x86.ActiveCfg = Debug|x86
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Debug|x86.Build.0 = Debug|x86
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.ActiveCfg = Release|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.Build.0 = Release|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x64.Deploy.0 = Release|x64
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|Any CPU.Build.0 = Release|Any CPU
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x86.ActiveCfg = Release|x86
{E6F5F1A0-D0FD-4E6B-A76D-1E8E3DAE8606}.Release|x86.Build.0 = Release|x86
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.ActiveCfg = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x64.Build.0 = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x86.ActiveCfg = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Debug|x86.Build.0 = Debug|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x64.ActiveCfg = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x64.Build.0 = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|Any CPU.Build.0 = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x86.ActiveCfg = Release|Any CPU
{CCFB7CB0-F898-444F-A16B-E28554A2698A}.Release|x86.Build.0 = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x64.ActiveCfg = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x64.Build.0 = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x86.ActiveCfg = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Debug|x86.Build.0 = Debug|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x64.ActiveCfg = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x64.Build.0 = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|Any CPU.Build.0 = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x86.ActiveCfg = Release|Any CPU
{7A0CD46C-0E0D-4C67-A819-0A4576849FAB}.Release|x86.Build.0 = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x64.ActiveCfg = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x64.Build.0 = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x86.ActiveCfg = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Debug|x86.Build.0 = Debug|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x64.ActiveCfg = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x64.Build.0 = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|Any CPU.Build.0 = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x86.ActiveCfg = Release|Any CPU
{040E8620-035C-4F62-B408-C05C5BDC90FE}.Release|x86.Build.0 = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x64.ActiveCfg = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x64.Build.0 = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x86.ActiveCfg = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Debug|x86.Build.0 = Debug|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x64.ActiveCfg = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x64.Build.0 = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|Any CPU.Build.0 = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x86.ActiveCfg = Release|Any CPU
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50}.Release|x86.Build.0 = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x64.ActiveCfg = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x64.Build.0 = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x86.ActiveCfg = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Debug|x86.Build.0 = Debug|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x64.ActiveCfg = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x64.Build.0 = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|Any CPU.Build.0 = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x86.ActiveCfg = Release|Any CPU
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B}.Release|x86.Build.0 = Release|Any CPU
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.Debug|x64.ActiveCfg = Debug|x64
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.Debug|x64.Build.0 = Debug|x64
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.Release|x64.ActiveCfg = Release|x64
{8275871E-4ECB-465B-AA77-D09E61CC1D84}.Release|x64.Build.0 = Release|x64
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.Debug|x64.ActiveCfg = Debug|x64
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.Debug|x64.Build.0 = Debug|x64
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.Release|x64.ActiveCfg = Release|x64
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2}.Release|x64.Build.0 = Release|x64
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Debug|x64.ActiveCfg = Debug|x64
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Debug|x64.Build.0 = Debug|x64
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.ActiveCfg = Release|x64
{515E56D2-8F90-45A0-83BC-44BD541B10A6}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -115,6 +80,9 @@ Global
{040E8620-035C-4F62-B408-C05C5BDC90FE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{1CB5ADD4-D6D5-4D96-A234-D181A1DEDE50} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{6105AAD6-CFE9-451B-BA48-4CF0142DD68B} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{8275871E-4ECB-465B-AA77-D09E61CC1D84} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{74DE0671-7705-4EA9-8BCB-BA98FD86C6B2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{515E56D2-8F90-45A0-83BC-44BD541B10A6} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {58E98A35-4C8C-4C80-9227-1063B345AD07}

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);
}

View File

@@ -27,6 +27,7 @@ public sealed partial class MainWindow : Window
{
InitializeComponent();
InitializeDatabaseRuntime();
InitializePlayoutRuntime();
Root.Loaded += OnRootLoaded;
Closed += OnClosed;
}
@@ -60,6 +61,7 @@ public sealed partial class MainWindow : Window
if (_webViewReady)
{
_ = MonitorDatabaseHealthAsync(_lifetimeCancellation.Token);
_ = ConnectPlayoutAsync(_lifetimeCancellation.Token);
}
}
@@ -124,7 +126,9 @@ public sealed partial class MainWindow : Window
return;
}
if (uri.Host.Equals(AppHost, StringComparison.OrdinalIgnoreCase) || uri.Scheme == "about")
if ((uri.Scheme == Uri.UriSchemeHttps &&
uri.Host.Equals(AppHost, StringComparison.OrdinalIgnoreCase)) ||
uri.Scheme == "about")
{
LoadingOverlay.Visibility = Visibility.Visible;
return;
@@ -162,6 +166,13 @@ public sealed partial class MainWindow : Window
private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs args)
{
if (!Uri.TryCreate(args.Source, UriKind.Absolute, out var source) ||
source.Scheme != Uri.UriSchemeHttps ||
!source.Host.Equals(AppHost, StringComparison.OrdinalIgnoreCase))
{
return;
}
try
{
var request = JsonSerializer.Deserialize<WebRequest>(args.WebMessageAsJson, JsonOptions);
@@ -172,10 +183,17 @@ public sealed partial class MainWindow : Window
case "request-app-info":
PostAppInfo();
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
PostPlayoutStatus();
break;
case "request-database-status":
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
break;
case "request-playout-status":
HandlePlayoutStatusRequest(request.Payload);
break;
case "playout-command":
_ = HandlePlayoutCommandAsync(request.Payload);
break;
case "request-market-data" when request.Payload.ValueKind == JsonValueKind.Object:
_ = HandleMarketDataRequestAsync(request.Payload);
break;
@@ -501,6 +519,7 @@ public sealed partial class MainWindow : Window
var wasWebViewReady = _webViewReady;
_webViewReady = false;
_lifetimeCancellation.Cancel();
ShutdownPlayoutRuntime();
var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null);
requestCancellation?.Cancel();
requestCancellation?.Dispose();

View File

@@ -24,9 +24,12 @@
- 원본의 공급자 비의존 SQL/데이터 요청 71개를 .NET 8 Core 프로젝트로 이관
- Oracle/MariaDB 실제 비동기 `IDataQueryExecutor`와 소스별 health/retry/timeout
- WebView 코스피·코스닥·NXT·5개 지수·해외 실데이터 조회와 장애 UI
- COM 중립 `IPlayoutEngine`, x64 K3D late binding, 전용 STA 큐와 프로세스 감시
- 네이티브 결과가 성공한 뒤에만 갱신되는 `PREPARE` / `TAKE IN` / `NEXT` / `TAKE OUT` WebView 브리지
- 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트
- MSIX 패키지 매니페스트와 x64 게시 프로필
Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D 송출은 아직 어댑터를 연결하지 않았으며, 화면의 송출 버튼은 운영 흐름 검증용입니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요.
Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D는 안전 기본값인 `DryRun`과 실제 어댑터 경계를 구현했습니다. 현재 장비에는 `PGM` Tornado만 실행 중이므로 그 프로세스에는 COM을 활성화하지 않았고, 실제 송출 호출 검증은 별도 Test 인스턴스·채널·씬이 준비된 뒤 진행합니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정과 롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요.
## Visual Studio 2026에서 실행
@@ -47,6 +50,15 @@ dotnet restore MBN_STOCK_WEBVIEW.sln -p:Platform=x64
dotnet build MBN_STOCK_WEBVIEW.sln -c Debug -p:Platform=x64
```
K3D 등록 상태와 COM을 열지 않는 dry-run 송출 흐름 확인:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Inspect-K3DRegistration.ps1
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
-c Debug -p:Platform=x64 -- --dry-run
```
MSIX 생성:
```powershell
@@ -73,8 +85,10 @@ MainWindow.xaml(.cs) 네이티브 창, WebView 초기화, 보안 경
Web/ FarPoint를 대체하는 로컬 운영 UI
src/MBN_STOCK_WEBVIEW.Core/ 이관된 데이터 요청 및 공급자 비의존 Core
src/MBN_STOCK_WEBVIEW.Infrastructure/ Oracle/MariaDB 공급자·설정·복원력·health
tests/ Core/Infrastructure 단위 테스
src/MBN_STOCK_WEBVIEW.Playout/ x64 Tornado/K3D STA·COM 어댑터와 안전 게이
tests/ Core/Infrastructure/Playout 단위 테스트
tools/MBN_STOCK_WEBVIEW.DbSmoke/ 비밀값을 출력하지 않는 실제 DB 스모크
tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/ COM 비활성 등록 probe와 dry-run 스모크
Config/ 비밀값 없는 구성 템플릿
ThirdPartyNotices/ 공급자 재배포 라이선스 고지
docs/ 마이그레이션 기록과 다음 단계

View File

@@ -36,7 +36,23 @@
selectedRows: new Set(),
currentIndex: -1,
dragIndex: -1,
playout: "IDLE",
playout: {
mode: "unknown",
engineState: "IDLE",
processDetected: false,
connected: false,
liveTakeInAllowed: false,
message: "송출 어댑터 상태를 확인하고 있습니다.",
preparedCode: null,
onAirCode: null,
preparedCue: null,
changedAt: null,
changedAtMs: 0,
latestStatusRequestId: null,
pending: null,
error: null,
lastSignature: ""
},
database: { sources: [], loading: true, lastSignature: "" },
liveData: {
requestId: null,
@@ -66,7 +82,22 @@
playlistBody: document.querySelector("#playlistBody"),
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
playoutState: document.querySelector("#playoutState"), eventLog: document.querySelector("#eventLog"),
playoutState: document.querySelector("#playoutState"),
playoutSummary: document.querySelector("#playoutSummary"),
playoutSummaryIcon: document.querySelector("#playoutSummaryIcon"),
playoutSummaryDetail: document.querySelector("#playoutSummaryDetail"),
playoutSummaryBadge: document.querySelector("#playoutSummaryBadge"),
tornadoProcessDot: document.querySelector("#tornadoProcessDot"),
tornadoProcessState: document.querySelector("#tornadoProcessState"),
playoutConnectionDot: document.querySelector("#playoutConnectionDot"),
playoutConnectionState: document.querySelector("#playoutConnectionState"),
playoutSafetyMode: document.querySelector("#playoutSafetyMode"),
playoutStatusMessage: document.querySelector("#playoutStatusMessage"),
playoutError: document.querySelector("#playoutError"),
playoutErrorMessage: document.querySelector("#playoutErrorMessage"),
prepareButton: document.querySelector("#prepareButton"), takeInButton: document.querySelector("#takeInButton"),
nextButton: document.querySelector("#nextButton"), takeOutButton: document.querySelector("#takeOutButton"),
eventLog: document.querySelector("#eventLog"),
toast: document.querySelector("#toast")
};
@@ -207,10 +238,12 @@
if (!item) {
elements.previewTitle.textContent = "선택된 그래픽 없음";
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
renderPlayout();
return;
}
elements.previewTitle.textContent = item.title;
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}`;
renderPlayout();
}
function onDragStart(event) {
@@ -294,34 +327,312 @@
}
}
function setPlayout(nextState, message) {
state.playout = nextState;
elements.playoutState.textContent = nextState;
elements.playoutState.classList.toggle("neutral", nextState === "IDLE");
addLog(message);
function normalizePlayoutState(value) {
const normalized = String(value || "").trim().toLocaleLowerCase("en-US").replace(/[\s_-]+/g, "");
if (["prepared", "ready", "cued"].includes(normalized)) return "PREPARED";
if (["program", "onair", "takein", "live"].includes(normalized)) return "PROGRAM";
if (["dryrun", "preview", "simulation", "simulated"].includes(normalized)) return "DRY RUN";
return "IDLE";
}
function prepare() {
if (!state.playlist[state.currentIndex]) return showToast("먼저 그래픽 항목을 선택하세요.");
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} PREPARE`);
function normalizePlayoutMode(value) {
return String(value || "unknown").trim().toLocaleLowerCase("en-US").replace(/[\s_]+/g, "-");
}
function takeIn() {
if (state.playout !== "PREPARED") return showToast("PREPARE가 먼저 필요합니다.");
setPlayout("PROGRAM", `${state.playlist[state.currentIndex].code} TAKE IN · Tornado 어댑터 대기`);
showToast("UI 동작 확인 완료 — 실제 송출 어댑터는 아직 연결되지 않았습니다.");
function isDryRunMode(mode = state.playout.mode) {
return /dry-?run|preview|simulat/.test(normalizePlayoutMode(mode));
}
function next() {
if (!state.playlist.length) return showToast("플레이리스트가 비어 있습니다.");
state.currentIndex = (state.currentIndex + 1) % state.playlist.length;
renderPlaylist();
updatePreview();
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} NEXT / PREPARE`);
function isLiveMode(mode = state.playout.mode) {
return /live|program/.test(normalizePlayoutMode(mode));
}
function takeOut() {
setPlayout("IDLE", "TAKE OUT");
function isTestMode(mode = state.playout.mode) {
return /test/.test(normalizePlayoutMode(mode));
}
function playoutDisplayState() {
return isDryRunMode() ? "DRY RUN" : state.playout.engineState;
}
function setStatusDot(element, status) {
element.classList.remove("pending", "healthy", "error", "warn", "ok", "unconfigured");
element.classList.add(status);
}
function cueFromItem(item) {
if (!item) return null;
return { code: String(item.code), title: String(item.title), detail: String(item.detail) };
}
function findCueByCode(code) {
if (!code) return null;
const current = state.playlist[state.currentIndex];
if (String(current?.code) === String(code)) return cueFromItem(current);
return cueFromItem(state.playlist.find(item => String(item.code) === String(code)));
}
function renderPlayout() {
const displayState = playoutDisplayState();
const pending = state.playout.pending;
const dryRun = isDryRunMode();
const liveMode = isLiveMode();
const testMode = isTestMode();
const bridgeReady = Boolean(nativeBridge);
const outcomeUnknown = state.playout.error?.outcomeUnknown === true;
const commandReady = bridgeReady && !pending && !outcomeUnknown && (state.playout.connected || dryRun);
const currentCue = cueFromItem(state.playlist[state.currentIndex]);
const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);
elements.playoutState.textContent = displayState;
elements.playoutState.classList.remove("neutral", "live", "dry-run", "error");
if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run");
elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지";
setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
elements.playoutConnectionState.textContent = state.playout.connected ? "연결됨" : "미연결";
setStatusDot(elements.playoutConnectionDot, state.playout.connected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item");
safetyContainer.classList.remove("live-allowed", "live-locked");
if (dryRun) {
elements.playoutSafetyMode.textContent = "DRY RUN · PROGRAM 차단";
} else if (testMode && state.playout.liveTakeInAllowed) {
elements.playoutSafetyMode.textContent = "TEST · 전용 출력 허용";
safetyContainer.classList.add("live-allowed");
} else if (testMode) {
elements.playoutSafetyMode.textContent = "TEST TAKE IN 잠금";
safetyContainer.classList.add("live-locked");
} else if (liveMode && state.playout.liveTakeInAllowed) {
elements.playoutSafetyMode.textContent = "LIVE 허용";
safetyContainer.classList.add("live-allowed");
} else if (liveMode) {
elements.playoutSafetyMode.textContent = "LIVE TAKE IN 잠금";
safetyContainer.classList.add("live-locked");
} else {
elements.playoutSafetyMode.textContent = bridgeReady ? "모드 확인 중" : "미리보기 전용";
}
const changedLabel = state.playout.changedAtMs
? ` · ${new Date(state.playout.changedAtMs).toLocaleTimeString("ko-KR", { hour12: false })}`
: "";
elements.playoutStatusMessage.textContent = pending
? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${changedLabel}`;
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error));
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
if (dryRun) {
elements.playoutSummary.classList.add("dry-run");
elements.playoutSummaryBadge.textContent = "DRY RUN";
elements.playoutSummaryDetail.textContent = bridgeReady ? "안전 모드 · PROGRAM 출력 차단" : "브라우저 미리보기 전용";
} else if (state.playout.connected) {
elements.playoutSummary.classList.add("ready");
elements.playoutSummaryBadge.textContent = testMode
? (state.playout.liveTakeInAllowed ? "TEST READY" : "TEST LOCKED")
: (state.playout.liveTakeInAllowed ? "LIVE READY" : "CONNECTED");
elements.playoutSummaryDetail.textContent = testMode
? "테스트 전용 출력 · 안전 게이트 적용"
: (state.playout.processDetected ? "Tornado2 프로세스 · 엔진 연결됨" : "엔진 연결됨 · 프로세스 미감지");
} else {
elements.playoutSummary.classList.add("error");
elements.playoutSummaryBadge.textContent = bridgeReady ? "OFFLINE" : "PREVIEW";
elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음";
}
elements.prepareButton.disabled = !commandReady || !currentCue;
elements.takeInButton.disabled = !commandReady || !prepared || !takeInSafe;
elements.nextButton.disabled = !commandReady || state.playlist.length === 0;
elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) {
button.setAttribute("aria-busy", String(Boolean(pending)));
}
elements.playoutError.hidden = !state.playout.error;
elements.playoutErrorMessage.textContent = state.playout.error?.message || "";
document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown ? "확인 후 잠금 해제" : "닫기";
}
function clearPlayoutError(render = true) {
state.playout.error = null;
if (render) renderPlayout();
}
function showPlayoutError({ code = "PLAYOUT_ERROR", message, retryable = false, outcomeUnknown = false }) {
const details = [message || "송출 명령을 완료하지 못했습니다."];
if (code) details.push(`[${code}]`);
if (outcomeUnknown) details.push("실제 송출 결과를 확인하세요.");
else if (retryable) details.push("상태 확인 후 재시도할 수 있습니다.");
state.playout.error = { code, message: details.join(" "), retryable, outcomeUnknown };
renderPlayout();
showToast(state.playout.error.message);
}
function resolveCommand(command) {
const current = state.playlist[state.currentIndex];
if (command === "prepare") {
return current ? { cue: cueFromItem(current), targetIndex: state.currentIndex } : null;
}
if (command === "take-in") {
const cue = state.playout.preparedCue || findCueByCode(state.playout.preparedCode);
return cue ? { cue, targetIndex: state.currentIndex } : null;
}
if (command === "next") {
if (!state.playlist.length) return null;
const targetIndex = state.currentIndex < 0 ? 0 : (state.currentIndex + 1) % state.playlist.length;
return { cue: cueFromItem(state.playlist[targetIndex]), targetIndex };
}
if (command === "take-out") return { cue: null, targetIndex: state.currentIndex };
return null;
}
function requestPlayoutCommand(command) {
if (!nativeBridge) {
showToast("브라우저 미리보기에서는 송출 명령을 실행하지 않습니다.");
return;
}
if (state.playout.pending) return;
const resolved = resolveCommand(command);
if (!resolved) {
const message = command === "take-in" ? "먼저 PREPARE를 완료하세요." : "먼저 그래픽 항목을 선택하세요.";
showToast(message);
return;
}
const requestId = createId();
const pending = {
requestId,
command,
cue: resolved.cue,
targetIndex: resolved.targetIndex,
timeoutId: null
};
state.playout.pending = pending;
clearPlayoutError(false);
renderPlayout();
addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);
const payload = { requestId, command };
if (resolved.cue) payload.cue = resolved.cue;
postNative("playout-command", payload);
pending.timeoutId = setTimeout(() => {
if (state.playout.pending?.requestId !== requestId) return;
state.playout.pending = null;
showPlayoutError({
code: "WEB_TIMEOUT",
message: "송출 엔진 응답 시간이 초과되었습니다.",
retryable: true,
outcomeUnknown: true
});
addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
requestPlayoutStatus();
}, 15000);
}
function requestPlayoutStatus() {
if (!nativeBridge) return;
const requestId = createId();
state.playout.latestStatusRequestId = requestId;
postNative("request-playout-status", { requestId });
}
function parseChangedAt(value) {
const parsed = typeof value === "string" || typeof value === "number" ? Date.parse(value) : NaN;
return Number.isFinite(parsed) ? parsed : 0;
}
function handlePlayoutStatus(payload) {
if (!payload || typeof payload !== "object") return;
if (payload.requestId && payload.requestId !== state.playout.latestStatusRequestId) return;
const changedAtMs = parseChangedAt(payload.changedAt);
if (changedAtMs && state.playout.changedAtMs && changedAtMs < state.playout.changedAtMs) return;
state.playout.mode = normalizePlayoutMode(payload.mode);
state.playout.engineState = normalizePlayoutState(payload.state);
state.playout.processDetected = payload.processDetected === true;
state.playout.connected = payload.connected === true;
state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
state.playout.message = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "Tornado 송출 상태를 수신했습니다.";
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
state.playout.preparedCue = findCueByCode(state.playout.preparedCode);
state.playout.changedAt = payload.changedAt || null;
state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;
renderPlayout();
const signature = [state.playout.mode, state.playout.engineState, state.playout.processDetected, state.playout.connected, state.playout.liveTakeInAllowed].join("|");
if (signature !== state.playout.lastSignature) {
addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`);
state.playout.lastSignature = signature;
}
}
function finishPendingCommand(payload) {
const pending = state.playout.pending;
if (!pending || payload?.requestId !== pending.requestId || payload?.command !== pending.command) return null;
clearTimeout(pending.timeoutId);
state.playout.pending = null;
return pending;
}
function handlePlayoutCommandResult(payload) {
const pending = finishPendingCommand(payload);
if (!pending) return;
if (payload.succeeded !== true) {
showPlayoutError({ message: payload.message || "송출 명령이 거부되었습니다.", retryable: true });
addLog(`${pending.command.toUpperCase()} 실패 · ${payload.message || "명령 거부"}`);
requestPlayoutStatus();
return;
}
clearPlayoutError(false);
if (payload.dryRun === true) state.playout.mode = "dry-run";
state.playout.engineState = normalizePlayoutState(payload.state);
state.playout.message = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: `${pending.command.toUpperCase()} 완료`;
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
if (pending.command === "prepare" || pending.command === "next") {
state.playout.preparedCue = pending.cue;
} else if (!state.playout.preparedCode) {
state.playout.preparedCue = null;
}
if (pending.command === "next") {
state.currentIndex = pending.targetIndex;
renderPlaylist();
updatePreview();
}
renderPlayout();
const safetyLabel = payload.dryRun === true ? "DRY RUN · " : "";
addLog(`${pending.cue?.code || "ENGINE"} ${safetyLabel}${pending.command.toUpperCase()} 완료`);
showToast(state.playout.message);
requestPlayoutStatus();
}
function handlePlayoutCommandError(payload) {
const pending = finishPendingCommand(payload);
if (!pending) return;
showPlayoutError({
code: payload.code,
message: payload.message,
retryable: payload.retryable === true,
outcomeUnknown: payload.outcomeUnknown === true
});
addLog(`${pending.command.toUpperCase()} 오류 · ${payload.code || "PLAYOUT_ERROR"} · ${payload.message || "명령 실패"}`);
requestPlayoutStatus();
}
function addLog(message) {
@@ -715,6 +1026,15 @@
case "market-data-error":
handleMarketDataError(message.payload);
break;
case "playout-status":
handlePlayoutStatus(message.payload);
break;
case "playout-command-result":
handlePlayoutCommandResult(message.payload);
break;
case "playout-command-error":
handlePlayoutCommandError(message.payload);
break;
}
}
@@ -745,14 +1065,16 @@
document.querySelector("#deleteButton").addEventListener("click", deleteSelected);
document.querySelector("#saveButton").addEventListener("click", savePlaylist);
document.querySelector("#loadButton").addEventListener("click", loadPlaylist);
document.querySelector("#prepareButton").addEventListener("click", prepare);
document.querySelector("#takeInButton").addEventListener("click", takeIn);
document.querySelector("#nextButton").addEventListener("click", next);
document.querySelector("#takeOutButton").addEventListener("click", takeOut);
elements.prepareButton.addEventListener("click", () => requestPlayoutCommand("prepare"));
elements.takeInButton.addEventListener("click", () => requestPlayoutCommand("take-in"));
elements.nextButton.addEventListener("click", () => requestPlayoutCommand("next"));
elements.takeOutButton.addEventListener("click", () => requestPlayoutCommand("take-out"));
document.querySelector("#clearPlayoutErrorButton").addEventListener("click", () => clearPlayoutError());
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
document.querySelector("#requestInfoButton").addEventListener("click", () => {
postNative("request-app-info");
requestDatabaseStatus();
requestPlayoutStatus();
if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
});
document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
@@ -764,9 +1086,9 @@
if (event.ctrlKey && event.key.toLowerCase() === "k") {
event.preventDefault(); elements.catalogSearch.focus(); return;
}
if (event.key === "F2") { event.preventDefault(); prepare(); }
if (event.key === "F8") { event.preventDefault(); takeIn(); }
if (event.key === "Escape") { event.preventDefault(); takeOut(); }
if (event.key === "F2") { event.preventDefault(); requestPlayoutCommand("prepare"); }
if (event.key === "F8") { event.preventDefault(); requestPlayoutCommand("take-in"); }
if (event.key === "Escape") { event.preventDefault(); requestPlayoutCommand("take-out"); }
});
}
@@ -776,6 +1098,7 @@
renderPlaylist();
renderDatabaseStatus();
renderLiveData();
renderPlayout();
updateClock();
setInterval(updateClock, 1000);
addLog("WebView 운영 화면 시작");
@@ -785,11 +1108,16 @@
postNative("ready");
postNative("request-app-info");
requestDatabaseStatus();
requestPlayoutStatus();
} else {
elements.bridgeState.textContent = "브라우저 미리보기";
state.database.loading = false;
state.playout.mode = "preview-dry-run";
state.playout.engineState = "IDLE";
state.playout.message = "Native Bridge 없음 · 미리보기 전용 DRY RUN (PROGRAM 출력 차단)";
renderPlayout();
renderDatabaseStatus();
addLog("Native Bridge 없이 브라우저 모드로 실행");
addLog("Native Bridge 없이 미리보기 전용 DRY RUN으로 실행");
}
}

View File

@@ -55,7 +55,7 @@
<article><span class="metric-icon mint"></span><div><strong>WebView UI</strong><small>FarPoint 대체 기반</small></div><b>READY</b></article>
<article><span class="metric-icon blue">71</span><div><strong>데이터 요청</strong><small>기존 SQL 계층 이관</small></div><b>MIGRATED</b></article>
<article id="databaseSummary"><span id="databaseSummaryIcon" class="metric-icon amber">DB</span><div><strong>Oracle · MariaDB</strong><small id="databaseSummaryDetail">연결 상태 확인 중</small></div><b id="databaseSummaryBadge">CHECKING</b></article>
<article><span class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small>x64 COM 검증 대기</small></div><b>PENDING</b></article>
<article id="playoutSummary" class="playout-summary"><span id="playoutSummaryIcon" class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small id="playoutSummaryDetail">상태 확인 중</small></div><b id="playoutSummaryBadge">CHECKING</b></article>
</section>
<div class="console-grid">
@@ -121,6 +121,27 @@
<div><p class="panel-kicker">PROGRAM / PREVIEW</p><h2>송출 제어</h2></div>
<span id="playoutState" class="badge neutral">IDLE</span>
</div>
<div class="playout-health" aria-label="Tornado 송출 연결 상태">
<div class="playout-health-item">
<span id="tornadoProcessDot" class="status-dot pending"></span>
<small>Tornado2 Process</small>
<strong id="tornadoProcessState">확인 중</strong>
</div>
<div class="playout-health-item">
<span id="playoutConnectionDot" class="status-dot pending"></span>
<small>Engine</small>
<strong id="playoutConnectionState">확인 중</strong>
</div>
<div class="playout-health-item safety">
<small>Safety Mode</small>
<strong id="playoutSafetyMode">확인 중</strong>
</div>
</div>
<div id="playoutStatusMessage" class="playout-status-message" role="status" aria-live="polite">송출 어댑터 상태를 확인하고 있습니다.</div>
<div id="playoutError" class="playout-error" role="alert" hidden>
<div><strong>송출 명령 오류</strong><span id="playoutErrorMessage"></span></div>
<button id="clearPlayoutErrorButton" type="button" aria-label="송출 오류 닫기">닫기</button>
</div>
<div class="preview-stage" id="previewStage">
<div class="safe-area">
<span>PREVIEW</span>

View File

@@ -133,6 +133,12 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
.database-summary.healthy > b { color: var(--mint); }
.database-summary.partial > b { color: var(--amber); }
.database-summary.error > b { color: var(--red); }
.playout-summary.ready { border-color: rgba(50,213,164,.22); }
.playout-summary.dry-run { border-color: rgba(255,186,85,.22); }
.playout-summary.error { border-color: rgba(255,102,128,.24); }
.playout-summary.ready > b { color: var(--mint); }
.playout-summary.dry-run > b { color: var(--amber); }
.playout-summary.error > b { color: var(--red); }
.console-grid { display: grid; grid-template-columns: minmax(250px, .73fr) minmax(410px, 1.2fr) minmax(330px, 1fr); gap: 12px; min-height: 680px; height: calc(100vh - 188px); }
@@ -142,6 +148,7 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
.badge { padding: 4px 7px; border: 1px solid rgba(50,213,164,.22); border-radius: 10px; background: var(--mint-soft); color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .05em; }
.badge.neutral { border-color: var(--border); background: var(--surface-2); color: var(--muted); }
.badge.live { border-color: rgba(50,213,164,.36); background: rgba(50,213,164,.16); box-shadow: 0 0 0 3px rgba(50,213,164,.04); }
.badge.dry-run { border-color: rgba(255,186,85,.32); background: rgba(255,186,85,.11); color: var(--amber); }
.search-box { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 6px; height: 40px; margin: 13px 13px 10px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #091522; }
.search-box > span { color: var(--muted); font-size: 17px; }
@@ -239,6 +246,23 @@ input[type="checkbox"] { accent-color: var(--mint); }
.empty-state span { color: var(--muted-2); font-size: 9px; }
.playout-panel { padding-bottom: 12px; }
.playout-health { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; margin: 10px 13px 0; }
.playout-health-item { display: grid; grid-template-columns: 7px minmax(0, 1fr); align-items: center; min-width: 0; min-height: 38px; column-gap: 6px; padding: 6px 8px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.72); }
.playout-health-item small { overflow: hidden; color: var(--muted-2); font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.playout-health-item strong { grid-column: 2; overflow: hidden; color: #b9c7d8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.playout-health-item.safety { grid-template-columns: 1fr; }
.playout-health-item.safety strong { grid-column: 1; color: var(--amber); }
.playout-health-item.safety.live-allowed strong { color: var(--mint); }
.playout-health-item.safety.live-locked strong { color: var(--red); }
.playout-status-message { min-height: 29px; margin: 7px 13px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(8,19,31,.52); color: #8296ad; font-size: 8px; line-height: 1.45; }
.playout-status-message.pending { border-color: rgba(86,168,255,.23); color: #8ec4ff; }
.playout-status-message.error { border-color: rgba(255,102,128,.22); color: #ff9bad; }
.playout-error { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 7px 13px 0; padding: 8px 9px; border: 1px solid rgba(255,102,128,.28); border-radius: 7px; background: rgba(255,102,128,.07); }
.playout-error[hidden] { display: none; }
.playout-error strong, .playout-error span { display: block; }
.playout-error strong { margin-bottom: 2px; color: #ff9bad; font-size: 8px; }
.playout-error span { color: #c98995; font-size: 8px; line-height: 1.4; }
.playout-error button { min-height: 24px; flex: 0 0 auto; padding: 0 7px; border: 1px solid rgba(255,102,128,.25); border-radius: 5px; background: transparent; color: #e38c9c; font-size: 8px; cursor: pointer; }
.preview-stage { position: relative; display: grid; min-height: 255px; margin: 13px; place-items: center; overflow: hidden; border: 1px solid #29405a; border-radius: 9px; background: radial-gradient(circle at 50% 45%, #193552, #08121f 60%); }
.preview-stage::after { position: absolute; inset: 0; background-image: linear-gradient(rgba(65,101,133,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(65,101,133,.08) 1px, transparent 1px); background-size: 24px 24px; content: ""; }
.safe-area { position: relative; z-index: 1; width: 78%; padding: 31px 20px; border: 1px dashed rgba(137,167,195,.24); color: var(--muted); text-align: center; }
@@ -253,6 +277,8 @@ input[type="checkbox"] { accent-color: var(--mint); }
.playout-controls button { display: flex; min-width: 0; height: 42px; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: #b5c4d5; font-size: 9px; font-weight: 700; cursor: pointer; }
.playout-controls button span { color: var(--muted-2); font: 8px Consolas, monospace; }
.playout-controls button:hover { filter: brightness(1.15); }
.playout-controls button:disabled { filter: none; cursor: not-allowed; opacity: .4; }
.playout-controls button[aria-busy="true"] { border-color: rgba(86,168,255,.36); box-shadow: inset 0 0 0 1px rgba(86,168,255,.08); opacity: .72; }
.playout-controls .prepare { border-color: rgba(86,168,255,.34); background: rgba(86,168,255,.1); color: #8ec4ff; }
.playout-controls .take-in { border-color: rgba(50,213,164,.38); background: var(--mint-soft); color: var(--mint); }
.playout-controls .take-out { border-color: rgba(255,102,128,.28); background: rgba(255,102,128,.08); color: #ff8fa2; }

View File

@@ -25,6 +25,9 @@
| 동기 Oracle/MySQL 연결 | 비동기 Oracle/MariaDB 공급자, 취소·timeout·선별 재시도 |
| DB 실패 시 `Application.Exit()` | 앱 유지 + WebView 소스별 health/오류/재조회 |
| 종목·지수 선택 데이터 | WebView의 KRX/NXT 종목 및 5개 지수 실데이터 표 |
| 직접 K3D COM 호출 | COM 중립 `IPlayoutEngine` + x64 late-bound 어댑터 |
| UI thread의 Tornado 호출 | bounded STA FIFO, timeout 격리, 재연결·프로세스 감시 |
| 버튼 즉시 상태 변경 | requestId 기반 WebView bridge와 실제 결과 후 상태 갱신 |
| AnyCPU/x86 혼재 | 솔루션 및 게시 프로필 x64 단일화 |
| 상대 경로 Web 파일 | MSIX Content 및 안전한 가상 호스트 매핑 |
@@ -43,11 +46,14 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일
### Tornado/K3D
- 설치된 x64 COM 타입 라이브러리에서 재현 가능한 Interop 생성
- `IPlayoutEngine` 경계로 COM 형식 격리
- STA 호출 직렬화와 프로세스 재연결
- MSIX 환경의 COM 활성화 및 벤더 재배포 조건 확인
- 기존 `StartsWith("Tornado2")` 프로세스 탐지 변경 반영
- 완료: Registry64의 K3D TypeLib/CLSID/ProgID/AMD64/Apartment 등록 검사
- 완료: 빌드 입력에 Interop DLL을 두지 않는 late binding과 선택적 진단용 `TlbImp` 스크립트
- 완료: `IPlayoutEngine` 경계, bounded STA FIFO/message pump, timeout 후 `OutcomeUnknown` 격리
- 완료: 연결/해제, 명시적 재연결(no replay), `Tornado2` 접두사 프로세스 감시
- 완료: `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` WebView 메시지 및 상태/오류 UI 연결
- 완료: 기본 DryRun, Test의 단일 loopback 테스트 인스턴스·채널·씬 allowlist, Live 이중 승인
- 확인 대기: 현재 `PGM`과 분리된 테스트 Tornado 인스턴스·출력 채널·테스트 씬의 실제 호출
- 확인 대기: 승인된 Test 환경에서 MSIX 컨텍스트의 실제 COM 활성화와 출력 관찰
### 화면 기능

187
docs/PLAYOUT.md Normal file
View File

@@ -0,0 +1,187 @@
# Tornado/K3D x64 송출 운영 가이드
## 안전 기준
기본 모드는 `DryRun`입니다. `DryRun`은 COM 객체를 만들거나 Tornado 출력에 명령을 보내지 않고 WebView 메시지, 큐, 상태 및 오류 표시 흐름만 검증합니다. 현재 방송 PROGRAM(PGM)에 연결된 Tornado 프로세스는 항상 **안전하지 않은 대상**으로 취급합니다. 프로세스 이름이나 실행 여부만으로 테스트 대상이라고 판단하지 않습니다.
이 저장소의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. `Live`는 운영 책임자의 명시적인 회차별 허가 없이는 사용하지 않습니다.
## 확인된 x64 COM 등록
현재 개발 장비에서 확인할 등록 기준은 다음과 같습니다.
| 항목 | 기대값 |
|---|---|
| 레지스트리 뷰 | `HKLM\SOFTWARE\Classes``Registry64`; 관련 HKCU override 없음 |
| TypeLib GUID | `{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}` |
| TypeLib 버전/대상 | `1.0` / `0\win64` |
| KAEngine CLSID | `{D756CDBE-AA31-42B2-9CC7-018753CA61BF}` |
| ProgID | `K3DAsyncEngine.KAEngine.1` |
| KAEventHandler CLSID / ProgID | `{39828C77-EFF0-4E59-979B-8673C028C718}` / `K3DAsyncEngine.KAEventHandler.1` |
| ThreadingModel | `Apartment` |
| DLL PE 대상 | TypeLib, KAEngine, KAEventHandler 모두 `AMD64` |
[Inspect-K3DRegistration.ps1](../scripts/Inspect-K3DRegistration.ps1)은 64비트 HKLM 등록에서 GUID, 버전과 `win64` TypeLib을 확인합니다. KAEngine과 KAEventHandler 각각의 CLSID→ProgID 및 ProgID→CLSID 양방향 매핑, `Apartment` 모델, `InprocServer32` 존재와 AMD64 PE 헤더도 검사합니다. HKCU 64비트 `Software\Classes`에 같은 TypeLib GUID, CLSID 또는 ProgID override가 하나라도 있으면 fail-closed합니다. 레지스트리와 파일을 읽기만 하며 COM을 활성화하거나 등록을 변경하지 않습니다.
`MBN_STOCK_WEBVIEW.PlayoutSmoke --probe`는 런타임과 같은 검사기를 통해 KAEngine과 KAEventHandler 등록을 함께 확인합니다. 이 probe도 COM 객체를 생성하지 않습니다.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Inspect-K3DRegistration.ps1
```
검사가 실패하면 x86 등록으로 대체하거나 DLL을 앱 폴더에 복사하지 않습니다. 벤더가 제공한 x64 설치 프로그램과 라이선스 절차로 장비 상태를 복구한 뒤 다시 검사합니다.
## 런타임 참조 방식
앱은 빌드 시점의 `Interop.K3DAsyncEngineLib.dll`을 참조하지 않습니다. HKLM 64비트 등록과 HKCU override 부재를 검증한 뒤 고정된 KAEngine/KAEventHandler CLSID로만 런타임 late binding하고, COM 객체와 호출 세부 사항은 `IPlayoutEngine` 구현 안에 격리합니다. 따라서 `obj` 폴더에 남은 Interop DLL 또는 특정 개발 장비의 벤더 설치 경로가 빌드 입력이 되지 않습니다.
타입 정보를 조사해야 할 때만 [Generate-K3DInterop.ps1](../scripts/Generate-K3DInterop.ps1)을 선택적으로 사용합니다. 생성 전 Inspect와 같은 HKLM 양방향 매핑·두 COM 서버 AMD64·HKCU override 부재 검사를 통과해야 합니다. 그 뒤 알려진 Windows SDK의 x64 `TlbImp.exe` 위치를 선택하거나 명시적으로 받은 x64 경로를 검증하고 `/machine:X64`로 생성합니다. 입력 TypeLib과 도구 및 결과물 모두 AMD64인지 확인하며, 결과는 Git에서 제외된 다음 위치에만 씁니다.
```text
artifacts\K3DInterop\Interop.K3DAsyncEngineLib.dll
```
실행 전 계획만 확인:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Generate-K3DInterop.ps1 -WhatIf
```
진단용 Interop 생성:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Generate-K3DInterop.ps1
```
SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-TlbImpPath`에 지정합니다. 생성물은 진단용일 뿐 프로젝트 참조나 MSIX 콘텐츠로 추가하지 않습니다. 이 스크립트도 COM 활성화와 등록 변경은 하지 않습니다.
## 로컬 설정
런타임 설정 기본 경로는 다음과 같습니다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json
```
[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 외부 씬 루트와 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. 테스트 장비의 호스트, 채널, 창 제목 패턴, 외부 씬 루트와 허용할 씬 이름은 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다.
| 속성 | 의미 |
|---|---|
| `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 |
| `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint |
| `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 |
| `sceneDirectory` | Test/Live에서 사용하는 외부 `.t2s` 루트의 절대 경로. `null`은 안전한 미설정 상태 |
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
| `layoutIndex` | 씬 플레이어 layout 위치 |
| `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 |
| `testSceneAllowlist` | `Test`에서 허용한 테스트 scene name(code) 목록. 경로나 확장자는 넣지 않음 |
| `trustedLiveOutputEnabled` | 운영자가 로컬 파일에서만 설정하는 라이브 1차 게이트 |
| `queueCapacity` | 직렬 STA 명령 큐의 최대 대기 항목 수 |
| `*TimeoutMilliseconds` | 연결, 작업 및 해제 제한 시간 |
| `processPollIntervalMilliseconds` | `Tornado2` 접두사 프로세스 감시 주기 |
| `reconnect*` | 재연결 지연, 최대 횟수 및 활성화 여부 |
JSON보다 다음 환경 변수가 우선합니다.
```text
MBN_STOCK_PLAYOUT_MODE
MBN_STOCK_PLAYOUT_HOST
MBN_STOCK_PLAYOUT_PORT
MBN_STOCK_PLAYOUT_TCP_MODE
MBN_STOCK_PLAYOUT_CLIENT_PORT
MBN_STOCK_PLAYOUT_SCENE_DIRECTORY
MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL
MBN_STOCK_PLAYOUT_LAYOUT_INDEX
MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN
MBN_STOCK_PLAYOUT_QUEUE_CAPACITY
MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS
MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS
MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS
MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS
MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS
MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS
MBN_STOCK_PLAYOUT_RECONNECT_ENABLED
```
`testSceneAllowlist``trustedLiveOutputEnabled`는 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 Test allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다.
## 모드와 안전 게이트
`Disabled`는 모든 송출 명령을 거부하는 운영 롤백 모드입니다. `DryRun`은 COM 없이 WebView 동작을 성공 결과로 모의합니다. `Test``Live`만 등록된 COM을 사용할 수 있습니다.
`Test` 전환 전 다음 조건을 모두 사람이 확인합니다.
1. Tornado 인스턴스가 현재 PGM과 물리적·논리적으로 분리되어 있고 Test의 `host`가 로컬 loopback IP literal(`127.0.0.1` 또는 `::1`)입니다. `localhost`를 포함한 호스트 이름은 허용하지 않습니다.
2. `testProcessWindowTitlePattern`이 전용 테스트 인스턴스 하나만 식별합니다.
3. `outputChannel`이 라우터/엔진에서 테스트 출력임을 확인했습니다.
4. `sceneDirectory`가 승인된 테스트 자산 루트이고 사용할 scene name이 `testSceneAllowlist`에 있으며 운영 씬이 아닙니다.
5. 같은 플레이리스트로 `DryRun``PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` 메시지와 화면 상태를 먼저 확인했습니다.
프로세스 감시는 이름이 `Tornado2`로 시작하는 모든 프로세스를 대소문자 구분 없이 찾습니다. 이는 가용성 신호일 뿐 송출 권한이 아닙니다. Test에서는 Tornado2 프로세스가 정확히 하나이고 그 창 제목이 테스트 규칙에만 일치해야 하며, `PGM`/`PROGRAM` 창이 하나라도 있거나 여러 인스턴스가 모호하면 COM을 활성화하지 않습니다. 즉 현재 방송 PGM이 실행 중인 장비에서는 Test 연결을 거부합니다.
Test에서 로컬 프로세스/창 제목 검사를 원격 KTAP endpoint의 신원 증명으로 사용하지 않습니다. 따라서 Test 모드는 loopback endpoint만 허용하며, 원격 host는 아래의 Live 이중 승인을 모두 통과한 경우에만 사용할 수 있습니다.
`Live`에는 별도의 이중 승인이 필요합니다.
1. 로컬 JSON의 `trustedLiveOutputEnabled`를 명시적으로 `true`로 설정합니다.
2. 그 회차에만 프로세스 환경 변수 `MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT`을 정확히 `I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH`로 설정합니다.
둘 중 하나라도 없으면 라이브 출력을 거부해야 합니다. 이 값은 사용자/시스템 영구 환경 변수로 저장하지 않으며 앱 종료 후 제거합니다. 현재 작업에는 이 승인이 주어지지 않았으므로 `Live` 및 실제 PGM `TAKE IN` 검증을 수행하지 않습니다.
## 안전한 스모크 절차
먼저 `mode``DryRun`이고 라이브 환경 변수가 없는지 확인합니다.
```powershell
Remove-Item Env:MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT -ErrorAction SilentlyContinue
powershell -NoProfile -ExecutionPolicy Bypass `
-File .\scripts\Inspect-K3DRegistration.ps1
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
-c Debug -p:Platform=x64 -- --probe
dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke `
-c Debug -p:Platform=x64 -- --dry-run
dotnet test .\MBN_STOCK_WEBVIEW.sln -c Debug -p:Platform=x64
dotnet test .\MBN_STOCK_WEBVIEW.sln -c Release -p:Platform=x64
```
`PlayoutSmoke --probe`의 기본 출력은 등록 준비 여부·issue 이름, Tornado2 프로세스 수와 PROGRAM 감지 여부만 제공합니다. 벤더 DLL 전체 경로, 프로세스 ID/이름 및 창 제목은 출력하거나 로그에 남기지 않습니다. 정확한 로컬 등록 경로가 필요한 관리자는 `Inspect-K3DRegistration.ps1` 결과를 해당 장비에서만 확인하고 지원 첨부파일에 포함하지 않습니다.
앱을 실행한 뒤 다음을 확인합니다.
1. 시작 상태가 `DRY RUN`으로 보이고 앱이 COM 또는 Tornado 없이 종료되지 않습니다.
2. WebView의 `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT`이 native bridge를 통과해 명확한 결과를 표시합니다.
3. 잘못된 설정 또는 엔진 부재가 앱 종료가 아니라 연결 상태와 안전한 오류 메시지로 표시됩니다.
4. x64 MSIX를 설치해도 같은 dry-run 흐름이 동작합니다.
실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode``Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → TAKE IN → NEXT → TAKE OUT` 결과를 관찰합니다. PGM/운영 출력에 변화가 보이면 즉시 앱을 종료하고 롤백합니다. 명령 timeout 뒤 결과가 불명확하면 명령을 자동 또는 수동으로 반복하지 말고 테스트 출력 상태를 먼저 확인합니다.
패키지 스모크에서는 벤더 x64 COM이 장비에 정식 등록되어 있어야 합니다. MSIX에 벤더 DLL을 복사해 활성화 오류를 우회하지 않습니다. 패키지 컨텍스트에서 COM 활성화가 막히면 `DryRun` 또는 `Disabled`를 유지하고 HRESULT와 등록 검사 결과만 보고합니다.
## 장애 및 롤백
가장 빠른 롤백은 로컬 설정을 다음처럼 바꾸고 앱을 재시작하는 것입니다.
```json
{
"mode": "Disabled"
}
```
그 다음 회차별 라이브 환경 변수를 제거하고 테스트/운영 라우팅 상태를 사람이 확인합니다. 필요하면 조직 배포 절차로 직전 서명 MSIX로 되돌립니다. COM 등록 해제, 벤더 설치 폴더 삭제 또는 DLL 교체는 앱 롤백 절차가 아니므로 수행하지 않습니다. 진단용 `artifacts\K3DInterop`을 삭제해도 런타임에는 영향이 없습니다.
연결 장애나 Tornado 재시작은 화면의 `Disconnected`, `Reconnecting`, `Faulted` 또는 `OutcomeUnknown` 상태로 판단합니다. 재연결 횟수를 모두 소진하거나 결과가 불명확한 송출 명령이 있으면 자동 재송출하지 않고 `Disabled`로 전환한 뒤 운영자가 테스트 출력 상태를 확인합니다.
## 저장소 반입 금지
다음 항목은 Git과 MSIX에 넣지 않습니다.
- 벤더 COM DLL 및 생성한 Interop DLL
- Tornado/K3D 라이선스 파일, 키 또는 인증서
- 실제 `.t2s` 씬, 이미지, 영상 및 방송 자산
- 운영 호스트, 채널, 창 제목 및 로컬 허용 목록이 든 설정
- 실제 출력 캡처나 비밀정보가 포함된 진단 로그
벤더 바이너리와 자산은 승인된 설치·배포 위치에서 관리하고, 앱 저장소에는 COM 중립 인터페이스와 안전한 설정 예시만 유지합니다.

View File

@@ -0,0 +1,440 @@
#Requires -Version 5.1
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
param(
[string] $TlbImpPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$expectedTypeLibId = '{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}'
$expectedTypeLibVersion = '1.0'
$expectedThreadingModel = 'Apartment'
$amd64Machine = 0x8664
$expectedClasses = @(
[pscustomobject]@{
Name = 'KAEngine'
ClassId = '{D756CDBE-AA31-42B2-9CC7-018753CA61BF}'
ProgId = 'K3DAsyncEngine.KAEngine.1'
},
[pscustomobject]@{
Name = 'KAEventHandler'
ClassId = '{39828C77-EFF0-4E59-979B-8673C028C718}'
ProgId = 'K3DAsyncEngine.KAEventHandler.1'
}
)
function Get-RegistryValue {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryKey] $BaseKey,
[Parameter(Mandatory = $true)]
[string] $SubKey,
[string] $Name = ''
)
$key = $BaseKey.OpenSubKey($SubKey, $false)
if ($null -eq $key) {
return $null
}
try {
return $key.GetValue(
$Name,
$null,
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
}
finally {
$key.Dispose()
}
}
function Assert-TextEqual {
param(
[AllowNull()]
[object] $Actual,
[Parameter(Mandatory = $true)]
[string] $Expected,
[Parameter(Mandatory = $true)]
[string] $Description
)
if ($null -eq $Actual -or
-not [string]::Equals(
[string] $Actual,
$Expected,
[StringComparison]::OrdinalIgnoreCase)) {
throw "$Description mismatch. Expected '$Expected'; found '$Actual'."
}
}
function Assert-GuidEqual {
param(
[AllowNull()]
[object] $Actual,
[Parameter(Mandatory = $true)]
[string] $Expected,
[Parameter(Mandatory = $true)]
[string] $Description
)
$actualGuid = [Guid]::Empty
$expectedGuid = [Guid]::Empty
if ($null -eq $Actual -or
-not [Guid]::TryParse([string] $Actual, [ref] $actualGuid) -or
-not [Guid]::TryParse($Expected, [ref] $expectedGuid) -or
$actualGuid -ne $expectedGuid) {
throw "$Description mismatch. Expected '$Expected'; found '$Actual'."
}
}
function Resolve-RegisteredFilePath {
param(
[AllowNull()]
[object] $RegistryValue,
[Parameter(Mandatory = $true)]
[string] $Description
)
if ($null -eq $RegistryValue -or [string]::IsNullOrWhiteSpace([string] $RegistryValue)) {
throw "$Description is not registered."
}
$expanded = [Environment]::ExpandEnvironmentVariables([string] $RegistryValue)
$expanded = $expanded.Trim().Trim('"')
if (-not [IO.Path]::IsPathRooted($expanded)) {
throw "$Description is not an absolute path."
}
$fullPath = [IO.Path]::GetFullPath($expanded)
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
throw "$Description does not exist: $fullPath"
}
return $fullPath
}
function Get-PeMachine {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
$stream = [IO.File]::Open(
$Path,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
$reader = [IO.BinaryReader]::new($stream)
try {
if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5A4D) {
throw "Not a PE file: $Path"
}
$stream.Position = 0x3C
$peOffset = $reader.ReadInt32()
if ($peOffset -lt 0 -or ($peOffset + 6) -gt $stream.Length) {
throw "Invalid PE header offset: $Path"
}
$stream.Position = $peOffset
if ($reader.ReadUInt32() -ne 0x00004550) {
throw "Invalid PE signature: $Path"
}
$machineCode = $reader.ReadUInt16()
$machineName = switch ($machineCode) {
0x014C { 'I386' }
0x8664 { 'AMD64' }
0xAA64 { 'ARM64' }
default { 'Unknown' }
}
return [pscustomobject]@{
Code = $machineCode
Name = $machineName
}
}
finally {
$reader.Dispose()
$stream.Dispose()
}
}
function Assert-Amd64File {
param(
[Parameter(Mandatory = $true)]
[string] $Path,
[Parameter(Mandatory = $true)]
[string] $Description
)
$machine = Get-PeMachine -Path $Path
if ($machine.Code -ne $amd64Machine) {
throw "$Description is not AMD64. PE machine is $($machine.Name) (0x$('{0:X4}' -f $machine.Code))."
}
}
function Test-RegistryKeyExists {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryKey] $BaseKey,
[Parameter(Mandatory = $true)]
[string] $SubKey
)
$key = $BaseKey.OpenSubKey($SubKey, $false)
if ($null -eq $key) {
return $false
}
$key.Dispose()
return $true
}
function Assert-NoCurrentUserOverrides {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryKey] $CurrentUserRoot
)
$paths = @("Software\Classes\TypeLib\$expectedTypeLibId")
foreach ($registration in $expectedClasses) {
$paths += "Software\Classes\CLSID\$($registration.ClassId)"
$paths += "Software\Classes\$($registration.ProgId)"
}
foreach ($path in $paths) {
if (Test-RegistryKeyExists -BaseKey $CurrentUserRoot -SubKey $path) {
throw "A per-user Registry64 K3D COM override is present at HKCU:\$path. Remove the override through the approved vendor recovery procedure before use."
}
}
}
function Assert-ComClassRegistration {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryKey] $MachineRoot,
[Parameter(Mandatory = $true)]
[object] $Registration
)
$classesPrefix = 'SOFTWARE\Classes'
$progIdClassId = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey "$classesPrefix\$($Registration.ProgId)\CLSID"
Assert-GuidEqual `
-Actual $progIdClassId `
-Expected $Registration.ClassId `
-Description "$($Registration.Name) ProgID to CLSID mapping"
$classProgId = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\ProgID"
Assert-TextEqual `
-Actual $classProgId `
-Expected $Registration.ProgId `
-Description "$($Registration.Name) CLSID to ProgID mapping"
$classTypeLibId = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\TypeLib"
Assert-GuidEqual `
-Actual $classTypeLibId `
-Expected $expectedTypeLibId `
-Description "$($Registration.Name) TypeLib GUID"
$classTypeLibVersion = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\Version"
if ($null -ne $classTypeLibVersion -and
-not [string]::IsNullOrWhiteSpace([string] $classTypeLibVersion)) {
Assert-TextEqual `
-Actual $classTypeLibVersion `
-Expected $expectedTypeLibVersion `
-Description "$($Registration.Name) TypeLib version"
}
$inprocKey = "$classesPrefix\CLSID\$($Registration.ClassId)\InprocServer32"
$serverPath = Resolve-RegisteredFilePath `
-RegistryValue (Get-RegistryValue -BaseKey $MachineRoot -SubKey $inprocKey) `
-Description "$($Registration.Name) InprocServer32 path"
$threadingModel = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey $inprocKey `
-Name 'ThreadingModel'
Assert-TextEqual `
-Actual $threadingModel `
-Expected $expectedThreadingModel `
-Description "$($Registration.Name) threading model"
Assert-Amd64File -Path $serverPath -Description "$($Registration.Name) COM server DLL"
}
function Resolve-X64TlbImpPath {
param(
[AllowEmptyString()]
[string] $ExplicitPath
)
if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) {
if (-not [IO.Path]::IsPathRooted($ExplicitPath)) {
throw 'TlbImpPath must be an absolute path.'
}
$resolvedExplicitPath = [IO.Path]::GetFullPath($ExplicitPath)
if (-not (Test-Path -LiteralPath $resolvedExplicitPath -PathType Leaf)) {
throw "TlbImp.exe was not found: $resolvedExplicitPath"
}
if (-not [string]::Equals(
[IO.Path]::GetFileName($resolvedExplicitPath),
'TlbImp.exe',
[StringComparison]::OrdinalIgnoreCase)) {
throw 'TlbImpPath must identify TlbImp.exe.'
}
Assert-Amd64File -Path $resolvedExplicitPath -Description 'TlbImp.exe'
return $resolvedExplicitPath
}
$programFilesX86 = [Environment]::GetFolderPath(
[Environment+SpecialFolder]::ProgramFilesX86)
$sdkBin = Join-Path $programFilesX86 'Microsoft SDKs\Windows\v10.0A\bin'
$candidates = @(
(Join-Path $sdkBin 'NETFX 4.8.1 Tools\x64\TlbImp.exe'),
(Join-Path $sdkBin 'NETFX 4.8 Tools\x64\TlbImp.exe'),
(Join-Path $sdkBin 'NETFX 4.7.2 Tools\x64\TlbImp.exe'),
(Join-Path $sdkBin 'NETFX 4.6.2 Tools\x64\TlbImp.exe')
)
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
Assert-Amd64File -Path $candidate -Description 'TlbImp.exe'
return [IO.Path]::GetFullPath($candidate)
}
}
$candidateList = $candidates -join [Environment]::NewLine
throw "An x64 TlbImp.exe was not found in the known Windows SDK locations. Install the .NET Framework SDK or pass -TlbImpPath with an x64 SDK path. Checked:$([Environment]::NewLine)$candidateList"
}
if (-not [Environment]::Is64BitOperatingSystem) {
throw 'K3D x64 interop generation requires a 64-bit Windows operating system.'
}
$machineRoot = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
$currentUserRoot = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::CurrentUser,
[Microsoft.Win32.RegistryView]::Registry64)
try {
Assert-NoCurrentUserOverrides -CurrentUserRoot $currentUserRoot
$typeLibKey = "SOFTWARE\Classes\TypeLib\$expectedTypeLibId\$expectedTypeLibVersion\0\win64"
$registeredTypeLibPath = Resolve-RegisteredFilePath `
-RegistryValue (Get-RegistryValue -BaseKey $machineRoot -SubKey $typeLibKey) `
-Description 'K3D x64 type library path'
Assert-Amd64File -Path $registeredTypeLibPath -Description 'K3D type library DLL'
foreach ($registration in $expectedClasses) {
Assert-ComClassRegistration `
-MachineRoot $machineRoot `
-Registration $registration
}
}
finally {
$currentUserRoot.Dispose()
$machineRoot.Dispose()
}
$resolvedTlbImpPath = Resolve-X64TlbImpPath -ExplicitPath $TlbImpPath
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$outputDirectory = [IO.Path]::GetFullPath(
(Join-Path $repositoryRoot 'artifacts\K3DInterop'))
$outputPath = Join-Path $outputDirectory 'Interop.K3DAsyncEngineLib.dll'
if (-not $PSCmdlet.ShouldProcess(
$outputPath,
'Generate an x64 diagnostic interop assembly from the registered K3D type library')) {
[pscustomobject][ordered]@{
Status = 'NotGenerated'
TypeLibId = $expectedTypeLibId
TypeLibVersion = $expectedTypeLibVersion
TypeLibPath = $registeredTypeLibPath
RegistrationHive = 'HKLM'
CurrentUserOverrides = 'None'
ValidatedClasses = @($expectedClasses.Name)
TlbImpPath = $resolvedTlbImpPath
OutputPath = $outputPath
Machine = 'AMD64'
ComActivated = $false
}
return
}
[void] (New-Item -ItemType Directory -Path $outputDirectory -Force)
$stagingDirectory = Join-Path `
$outputDirectory `
('.staging-{0}-{1}' -f $PID, [Guid]::NewGuid().ToString('N'))
$stagedOutputPath = Join-Path $stagingDirectory 'Interop.K3DAsyncEngineLib.dll'
[void] (New-Item -ItemType Directory -Path $stagingDirectory)
try {
$tlbImpArguments = @(
$registeredTypeLibPath,
"/out:$stagedOutputPath",
'/namespace:K3DAsyncEngineLib',
'/machine:X64',
'/silent'
)
$tlbImpOutput = & $resolvedTlbImpPath @tlbImpArguments 2>&1
$tlbImpExitCode = $LASTEXITCODE
if ($tlbImpExitCode -ne 0) {
$diagnosticText = ($tlbImpOutput | Out-String).Trim()
throw "TlbImp.exe failed with exit code $tlbImpExitCode. $diagnosticText"
}
if (-not (Test-Path -LiteralPath $stagedOutputPath -PathType Leaf)) {
throw 'TlbImp.exe reported success but did not create the interop assembly.'
}
Assert-Amd64File -Path $stagedOutputPath -Description 'Generated interop assembly'
[IO.File]::Copy($stagedOutputPath, $outputPath, $true)
Assert-Amd64File -Path $outputPath -Description 'Final interop assembly'
$hash = Get-FileHash -LiteralPath $outputPath -Algorithm SHA256
[pscustomobject][ordered]@{
Status = 'Generated'
TypeLibId = $expectedTypeLibId
TypeLibVersion = $expectedTypeLibVersion
TypeLibPath = $registeredTypeLibPath
RegistrationHive = 'HKLM'
CurrentUserOverrides = 'None'
ValidatedClasses = @($expectedClasses.Name)
TlbImpPath = $resolvedTlbImpPath
OutputPath = $outputPath
Machine = 'AMD64'
Sha256 = $hash.Hash
ComActivated = $false
}
}
finally {
if (Test-Path -LiteralPath $stagingDirectory) {
Remove-Item -LiteralPath $stagingDirectory -Recurse -Force
}
}

View File

@@ -0,0 +1,338 @@
#Requires -Version 5.1
[CmdletBinding()]
param()
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$expectedTypeLibId = '{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}'
$expectedTypeLibVersion = '1.0'
$expectedThreadingModel = 'Apartment'
$amd64Machine = 0x8664
$expectedClasses = @(
[pscustomobject]@{
Name = 'KAEngine'
ClassId = '{D756CDBE-AA31-42B2-9CC7-018753CA61BF}'
ProgId = 'K3DAsyncEngine.KAEngine.1'
},
[pscustomobject]@{
Name = 'KAEventHandler'
ClassId = '{39828C77-EFF0-4E59-979B-8673C028C718}'
ProgId = 'K3DAsyncEngine.KAEventHandler.1'
}
)
function Get-RegistryValue {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryKey] $BaseKey,
[Parameter(Mandatory = $true)]
[string] $SubKey,
[string] $Name = ''
)
$key = $BaseKey.OpenSubKey($SubKey, $false)
if ($null -eq $key) {
return $null
}
try {
return $key.GetValue(
$Name,
$null,
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
}
finally {
$key.Dispose()
}
}
function Assert-TextEqual {
param(
[AllowNull()]
[object] $Actual,
[Parameter(Mandatory = $true)]
[string] $Expected,
[Parameter(Mandatory = $true)]
[string] $Description
)
if ($null -eq $Actual -or
-not [string]::Equals(
[string] $Actual,
$Expected,
[StringComparison]::OrdinalIgnoreCase)) {
throw "$Description mismatch. Expected '$Expected'; found '$Actual'."
}
}
function Assert-GuidEqual {
param(
[AllowNull()]
[object] $Actual,
[Parameter(Mandatory = $true)]
[string] $Expected,
[Parameter(Mandatory = $true)]
[string] $Description
)
$actualGuid = [Guid]::Empty
$expectedGuid = [Guid]::Empty
if ($null -eq $Actual -or
-not [Guid]::TryParse([string] $Actual, [ref] $actualGuid) -or
-not [Guid]::TryParse($Expected, [ref] $expectedGuid) -or
$actualGuid -ne $expectedGuid) {
throw "$Description mismatch. Expected '$Expected'; found '$Actual'."
}
}
function Resolve-RegisteredFilePath {
param(
[AllowNull()]
[object] $RegistryValue,
[Parameter(Mandatory = $true)]
[string] $Description
)
if ($null -eq $RegistryValue -or [string]::IsNullOrWhiteSpace([string] $RegistryValue)) {
throw "$Description is not registered."
}
$expanded = [Environment]::ExpandEnvironmentVariables([string] $RegistryValue)
$expanded = $expanded.Trim().Trim('"')
if (-not [IO.Path]::IsPathRooted($expanded)) {
throw "$Description is not an absolute path."
}
$fullPath = [IO.Path]::GetFullPath($expanded)
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
throw "$Description does not exist: $fullPath"
}
return $fullPath
}
function Get-PeMachine {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
$stream = [IO.File]::Open(
$Path,
[IO.FileMode]::Open,
[IO.FileAccess]::Read,
([IO.FileShare]::ReadWrite -bor [IO.FileShare]::Delete))
$reader = [IO.BinaryReader]::new($stream)
try {
if ($stream.Length -lt 64 -or $reader.ReadUInt16() -ne 0x5A4D) {
throw "Not a PE file: $Path"
}
$stream.Position = 0x3C
$peOffset = $reader.ReadInt32()
if ($peOffset -lt 0 -or ($peOffset + 6) -gt $stream.Length) {
throw "Invalid PE header offset: $Path"
}
$stream.Position = $peOffset
if ($reader.ReadUInt32() -ne 0x00004550) {
throw "Invalid PE signature: $Path"
}
$machineCode = $reader.ReadUInt16()
$machineName = switch ($machineCode) {
0x014C { 'I386' }
0x8664 { 'AMD64' }
0xAA64 { 'ARM64' }
default { 'Unknown' }
}
return [pscustomobject]@{
Code = $machineCode
Name = $machineName
}
}
finally {
$reader.Dispose()
$stream.Dispose()
}
}
function Assert-Amd64File {
param(
[Parameter(Mandatory = $true)]
[string] $Path,
[Parameter(Mandatory = $true)]
[string] $Description
)
$machine = Get-PeMachine -Path $Path
if ($machine.Code -ne $amd64Machine) {
throw "$Description is not AMD64. PE machine is $($machine.Name) (0x$('{0:X4}' -f $machine.Code))."
}
}
function Test-RegistryKeyExists {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryKey] $BaseKey,
[Parameter(Mandatory = $true)]
[string] $SubKey
)
$key = $BaseKey.OpenSubKey($SubKey, $false)
if ($null -eq $key) {
return $false
}
$key.Dispose()
return $true
}
function Assert-NoCurrentUserOverrides {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryKey] $CurrentUserRoot
)
$paths = @("Software\Classes\TypeLib\$expectedTypeLibId")
foreach ($registration in $expectedClasses) {
$paths += "Software\Classes\CLSID\$($registration.ClassId)"
$paths += "Software\Classes\$($registration.ProgId)"
}
foreach ($path in $paths) {
if (Test-RegistryKeyExists -BaseKey $CurrentUserRoot -SubKey $path) {
throw "A per-user Registry64 K3D COM override is present at HKCU:\$path. Remove the override through the approved vendor recovery procedure before use."
}
}
}
function Assert-ComClassRegistration {
param(
[Parameter(Mandatory = $true)]
[Microsoft.Win32.RegistryKey] $MachineRoot,
[Parameter(Mandatory = $true)]
[object] $Registration
)
$classesPrefix = 'SOFTWARE\Classes'
$progIdClassId = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey "$classesPrefix\$($Registration.ProgId)\CLSID"
Assert-GuidEqual `
-Actual $progIdClassId `
-Expected $Registration.ClassId `
-Description "$($Registration.Name) ProgID to CLSID mapping"
$classProgId = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\ProgID"
Assert-TextEqual `
-Actual $classProgId `
-Expected $Registration.ProgId `
-Description "$($Registration.Name) CLSID to ProgID mapping"
$classTypeLibId = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\TypeLib"
Assert-GuidEqual `
-Actual $classTypeLibId `
-Expected $expectedTypeLibId `
-Description "$($Registration.Name) TypeLib GUID"
$classTypeLibVersion = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey "$classesPrefix\CLSID\$($Registration.ClassId)\Version"
if ($null -ne $classTypeLibVersion -and
-not [string]::IsNullOrWhiteSpace([string] $classTypeLibVersion)) {
Assert-TextEqual `
-Actual $classTypeLibVersion `
-Expected $expectedTypeLibVersion `
-Description "$($Registration.Name) TypeLib version"
}
$inprocKey = "$classesPrefix\CLSID\$($Registration.ClassId)\InprocServer32"
$serverPath = Resolve-RegisteredFilePath `
-RegistryValue (Get-RegistryValue -BaseKey $MachineRoot -SubKey $inprocKey) `
-Description "$($Registration.Name) InprocServer32 path"
$threadingModel = Get-RegistryValue `
-BaseKey $MachineRoot `
-SubKey $inprocKey `
-Name 'ThreadingModel'
Assert-TextEqual `
-Actual $threadingModel `
-Expected $expectedThreadingModel `
-Description "$($Registration.Name) threading model"
Assert-Amd64File -Path $serverPath -Description "$($Registration.Name) COM server DLL"
return [pscustomobject][ordered]@{
Name = $Registration.Name
ClassId = $Registration.ClassId
ProgId = $Registration.ProgId
ServerPath = $serverPath
ServerMachine = 'AMD64'
ThreadingModel = $threadingModel
ReciprocalMapping = $true
}
}
if (-not [Environment]::Is64BitOperatingSystem) {
throw 'K3D x64 registration requires a 64-bit Windows operating system.'
}
$machineRoot = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::LocalMachine,
[Microsoft.Win32.RegistryView]::Registry64)
$currentUserRoot = [Microsoft.Win32.RegistryKey]::OpenBaseKey(
[Microsoft.Win32.RegistryHive]::CurrentUser,
[Microsoft.Win32.RegistryView]::Registry64)
try {
Assert-NoCurrentUserOverrides -CurrentUserRoot $currentUserRoot
$typeLibKey = "SOFTWARE\Classes\TypeLib\$expectedTypeLibId\$expectedTypeLibVersion\0\win64"
$typeLibPath = Resolve-RegisteredFilePath `
-RegistryValue (Get-RegistryValue -BaseKey $machineRoot -SubKey $typeLibKey) `
-Description 'K3D x64 type library path'
Assert-Amd64File -Path $typeLibPath -Description 'K3D type library DLL'
$classReports = @()
foreach ($registration in $expectedClasses) {
$classReports += Assert-ComClassRegistration `
-MachineRoot $machineRoot `
-Registration $registration
}
[pscustomobject][ordered]@{
Status = 'Valid'
RegistryHive = 'HKLM'
RegistryView = 'Registry64'
CurrentUserOverrides = 'None'
TypeLibId = $expectedTypeLibId
TypeLibVersion = $expectedTypeLibVersion
TypeLibTarget = 'win64'
TypeLibPath = $typeLibPath
TypeLibMachine = 'AMD64'
Classes = $classReports
PowerShellProcessIs64Bit = [Environment]::Is64BitProcess
ComActivated = $false
}
}
finally {
$currentUserRoot.Dispose()
$machineRoot.Dispose()
}

View File

@@ -0,0 +1,134 @@
#nullable enable
namespace MBN_STOCK_WEBVIEW.Core.Playout;
public enum PlayoutMode
{
Disabled,
DryRun,
Test,
Live
}
public enum PlayoutConnectionState
{
Disabled,
DryRunReady,
Disconnected,
Connecting,
Connected,
Reconnecting,
Disconnecting,
Faulted,
OutcomeUnknown,
Disposed
}
public enum PlayoutOperation
{
Connect,
Disconnect,
Prepare,
TakeIn,
Next,
TakeOut
}
public enum PlayoutResultCode
{
Success,
Rejected,
Cancelled,
TimedOut,
Unavailable,
Failed,
OutcomeUnknown
}
public enum PlayoutTakeOutScope
{
Layout,
All
}
/// <summary>
/// Describes a COM-neutral scene object update performed before QueryVariables.
/// </summary>
public sealed record PlayoutField(
string ObjectName,
string? Value = null,
bool? IsVisible = null);
/// <summary>
/// Identifies a scene and the neutral object changes needed to prepare it.
/// SceneFile is an input only and is never copied into user-facing status/error text.
/// </summary>
public sealed record PlayoutCue(
string SceneFile,
string SceneName,
IReadOnlyList<PlayoutField>? Fields = null,
int FadeDuration = 0);
public sealed record PlayoutStatus(
PlayoutMode Mode,
PlayoutConnectionState State,
bool IsConnected,
bool IsProcessRunning,
bool IsCommandAvailable,
bool LiveTakeInAllowed,
string? PreparedSceneName,
string? OnAirSceneName,
string Message,
DateTimeOffset ChangedAtUtc,
long Sequence);
public sealed class PlayoutStatusChangedEventArgs : EventArgs
{
public PlayoutStatusChangedEventArgs(PlayoutStatus previous, PlayoutStatus current)
{
Previous = previous;
Current = current;
}
public PlayoutStatus Previous { get; }
public PlayoutStatus Current { get; }
}
public sealed record PlayoutResult(
PlayoutOperation Operation,
PlayoutResultCode Code,
bool IsDryRun,
string Message,
DateTimeOffset CompletedAtUtc)
{
public bool IsSuccess => Code == PlayoutResultCode.Success;
}
/// <summary>
/// COM-free application boundary for all Tornado/K3D playout operations.
/// </summary>
public interface IPlayoutEngine : IAsyncDisposable
{
PlayoutStatus Status { get; }
event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged;
Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default);
Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default);
Task<PlayoutResult> PrepareAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default);
Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default);
Task<PlayoutResult> NextAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default);
Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,49 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
public sealed class PlayoutOptions
{
public PlayoutMode Mode { get; set; } = PlayoutMode.DryRun;
public string Host { get; set; } = "127.0.0.1";
public int Port { get; set; } = 30001;
/// <summary>KTAPConnect bTCP argument. The legacy application uses 1.</summary>
public int TcpMode { get; set; } = 1;
/// <summary>KTAPConnect nClientPort argument. Zero lets the SDK choose.</summary>
public int ClientPort { get; set; }
public int? OutputChannel { get; set; }
public int LayoutIndex { get; set; } = 10;
/// <summary>
/// External absolute root containing vendor .t2s scenes. Required in Test and Live modes.
/// </summary>
public string? SceneDirectory { get; set; }
public string? TestProcessWindowTitlePattern { get; set; }
public List<string> TestSceneAllowlist { get; set; } = [];
public bool TrustedLiveOutputEnabled { get; set; }
public int QueueCapacity { get; set; } = 64;
public int ConnectTimeoutMilliseconds { get; set; } = 5_000;
public int OperationTimeoutMilliseconds { get; set; } = 5_000;
public int DisconnectTimeoutMilliseconds { get; set; } = 3_000;
public int ProcessPollIntervalMilliseconds { get; set; } = 1_000;
public int ReconnectDelayMilliseconds { get; set; } = 1_000;
public int MaximumReconnectAttempts { get; set; } = 3;
public bool ReconnectEnabled { get; set; } = true;
}

View File

@@ -0,0 +1,201 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using MBN_STOCK_WEBVIEW.Core.Playout;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
public static class PlayoutOptionsLoader
{
public const string LiveAuthorizationEnvironmentVariable =
"MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT";
public const string RequiredLiveAuthorizationValue =
"I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH";
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"Config",
"playout.local.json");
public static PlayoutOptions Load(string? path = null)
{
var options = LoadJson(path ?? DefaultPath);
ApplyEnvironment(options);
return options;
}
internal static bool IsLiveAuthorizedForThisLaunch() =>
string.Equals(
Environment.GetEnvironmentVariable(LiveAuthorizationEnvironmentVariable),
RequiredLiveAuthorizationValue,
StringComparison.Ordinal);
private static PlayoutOptions LoadJson(string path)
{
if (!File.Exists(path))
{
return new PlayoutOptions();
}
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<PlayoutOptions>(json, JsonOptions())
?? new PlayoutOptions();
}
catch (JsonException exception)
{
throw new PlayoutConfigurationException(
"송출 설정 파일 형식이 올바르지 않습니다.", exception);
}
catch (IOException exception)
{
throw new PlayoutConfigurationException(
"송출 설정 파일을 읽을 수 없습니다.", exception);
}
catch (UnauthorizedAccessException exception)
{
throw new PlayoutConfigurationException(
"송출 설정 파일에 접근할 수 없습니다.", exception);
}
}
private static void ApplyEnvironment(PlayoutOptions options)
{
ApplyEnum("MBN_STOCK_PLAYOUT_MODE", value => options.Mode = value);
ApplyString("MBN_STOCK_PLAYOUT_HOST", value => options.Host = value);
ApplyInt("MBN_STOCK_PLAYOUT_PORT", value => options.Port = value);
ApplyInt("MBN_STOCK_PLAYOUT_TCP_MODE", value => options.TcpMode = value);
ApplyInt("MBN_STOCK_PLAYOUT_CLIENT_PORT", value => options.ClientPort = value);
ApplyNullableInt("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", value => options.OutputChannel = value);
ApplyInt("MBN_STOCK_PLAYOUT_LAYOUT_INDEX", value => options.LayoutIndex = value);
ApplyString("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", value => options.SceneDirectory = value);
ApplyString(
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
value => options.TestProcessWindowTitlePattern = value);
ApplyInt("MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", value => options.QueueCapacity = value);
ApplyInt(
"MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS",
value => options.ConnectTimeoutMilliseconds = value);
ApplyInt(
"MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS",
value => options.OperationTimeoutMilliseconds = value);
ApplyInt(
"MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS",
value => options.DisconnectTimeoutMilliseconds = value);
ApplyInt(
"MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS",
value => options.ProcessPollIntervalMilliseconds = value);
ApplyInt(
"MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS",
value => options.ReconnectDelayMilliseconds = value);
ApplyInt(
"MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS",
value => options.MaximumReconnectAttempts = value);
ApplyBool(
"MBN_STOCK_PLAYOUT_RECONNECT_ENABLED",
value => options.ReconnectEnabled = value);
}
private static JsonSerializerOptions JsonOptions()
{
var result = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
result.Converters.Add(new JsonStringEnumConverter());
return result;
}
private static void ApplyString(string name, Action<string> apply)
{
var value = Environment.GetEnvironmentVariable(name);
if (!string.IsNullOrWhiteSpace(value))
{
apply(value.Trim());
}
}
private static void ApplyInt(string name, Action<int> apply)
{
var text = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(text))
{
return;
}
if (!int.TryParse(text, out var value))
{
throw InvalidEnvironment(name);
}
apply(value);
}
private static void ApplyNullableInt(string name, Action<int?> apply)
{
var text = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(text))
{
return;
}
if (!int.TryParse(text, out var value))
{
throw InvalidEnvironment(name);
}
apply(value);
}
private static void ApplyBool(string name, Action<bool> apply)
{
var text = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(text))
{
return;
}
if (!bool.TryParse(text, out var value))
{
throw InvalidEnvironment(name);
}
apply(value);
}
private static void ApplyEnum(string name, Action<PlayoutMode> apply)
{
var text = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(text))
{
return;
}
if (!Enum.TryParse<PlayoutMode>(text, true, out var value))
{
throw InvalidEnvironment(name);
}
apply(value);
}
private static PlayoutConfigurationException InvalidEnvironment(string name) =>
new($"환경 변수 {name} 값이 올바르지 않습니다.");
}
public sealed class PlayoutConfigurationException : Exception
{
public PlayoutConfigurationException(string message)
: base(message)
{
}
public PlayoutConfigurationException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,374 @@
using System.Text.RegularExpressions;
using System.Net;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
internal sealed record ValidatedPlayoutOptions(
PlayoutMode Mode,
string Host,
int Port,
int TcpMode,
int ClientPort,
int? OutputChannel,
int LayoutIndex,
string? SceneDirectory,
Regex? TestProcessWindowTitleRegex,
IReadOnlySet<string> TestSceneAllowlist,
bool TrustedLiveOutputEnabled,
int QueueCapacity,
TimeSpan ConnectTimeout,
TimeSpan OperationTimeout,
TimeSpan DisconnectTimeout,
TimeSpan ProcessPollInterval,
TimeSpan ReconnectDelay,
int MaximumReconnectAttempts,
bool ReconnectEnabled)
{
public static ValidatedPlayoutOptions Create(PlayoutOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (!Enum.IsDefined(options.Mode))
{
throw Invalid(nameof(options.Mode));
}
var host = RequiredText(options.Host, nameof(options.Host));
RequireRange(options.Port, 1, 65_535, nameof(options.Port));
RequireRange(options.TcpMode, 0, 1, nameof(options.TcpMode));
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live && options.TcpMode != 1)
{
throw Invalid(nameof(options.TcpMode));
}
RequireRange(options.ClientPort, 0, 65_535, nameof(options.ClientPort));
if (options.OutputChannel is { } outputChannel)
{
RequireRange(outputChannel, 0, int.MaxValue, nameof(options.OutputChannel));
}
if (options.LayoutIndex != K3dComConstants.LegacyLayoutIndex)
{
throw Invalid(nameof(options.LayoutIndex));
}
string? sceneDirectory = null;
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live)
{
sceneDirectory = ValidateSceneDirectory(options.SceneDirectory);
}
RequireRange(options.QueueCapacity, 1, 4_096, nameof(options.QueueCapacity));
RequireRange(
options.ConnectTimeoutMilliseconds,
100,
300_000,
nameof(options.ConnectTimeoutMilliseconds));
RequireRange(
options.OperationTimeoutMilliseconds,
100,
300_000,
nameof(options.OperationTimeoutMilliseconds));
RequireRange(
options.DisconnectTimeoutMilliseconds,
100,
300_000,
nameof(options.DisconnectTimeoutMilliseconds));
RequireRange(
options.ProcessPollIntervalMilliseconds,
100,
60_000,
nameof(options.ProcessPollIntervalMilliseconds));
RequireRange(
options.ReconnectDelayMilliseconds,
0,
300_000,
nameof(options.ReconnectDelayMilliseconds));
RequireRange(
options.MaximumReconnectAttempts,
0,
1_000,
nameof(options.MaximumReconnectAttempts));
Regex? titleRegex = null;
var allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in options.TestSceneAllowlist ?? [])
{
var value = item?.Trim();
if (!IsSafeSceneCode(value))
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
allowlist.Add(value!);
}
if (options.Mode == PlayoutMode.Test)
{
if (!IsLiteralLoopback(host))
{
throw Invalid(nameof(options.Host));
}
if (options.OutputChannel is null)
{
throw Invalid(nameof(options.OutputChannel));
}
if (allowlist.Count == 0)
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
var pattern = RequiredText(
options.TestProcessWindowTitlePattern,
nameof(options.TestProcessWindowTitlePattern));
try
{
titleRegex = new Regex(
pattern,
RegexOptions.IgnoreCase |
RegexOptions.CultureInvariant |
RegexOptions.NonBacktracking,
TimeSpan.FromMilliseconds(250));
}
catch (ArgumentException exception)
{
throw new PlayoutConfigurationException(
"테스트 Tornado 창 제목 설정이 올바르지 않습니다.", exception);
}
if (CanMatchProgramTitle(titleRegex))
{
throw new PlayoutConfigurationException(
"테스트 창 제목 규칙은 PROGRAM 출력을 가리킬 수 없습니다.");
}
}
return new ValidatedPlayoutOptions(
options.Mode,
host,
options.Port,
options.TcpMode,
options.ClientPort,
options.OutputChannel,
options.LayoutIndex,
sceneDirectory,
titleRegex,
allowlist,
options.TrustedLiveOutputEnabled,
options.QueueCapacity,
TimeSpan.FromMilliseconds(options.ConnectTimeoutMilliseconds),
TimeSpan.FromMilliseconds(options.OperationTimeoutMilliseconds),
TimeSpan.FromMilliseconds(options.DisconnectTimeoutMilliseconds),
TimeSpan.FromMilliseconds(options.ProcessPollIntervalMilliseconds),
TimeSpan.FromMilliseconds(options.ReconnectDelayMilliseconds),
options.MaximumReconnectAttempts,
options.ReconnectEnabled);
}
public bool IsTestSceneAllowed(PlayoutCue cue)
{
var sceneName = cue.SceneName?.Trim();
return !string.IsNullOrEmpty(sceneName) && TestSceneAllowlist.Contains(sceneName);
}
public PlayoutCue ResolveCue(PlayoutCue cue)
{
ArgumentNullException.ThrowIfNull(cue);
var sceneName = cue.SceneName?.Trim();
if (!IsSafeSceneCode(sceneName))
{
throw new PlayoutRequestException("장면 이름이 올바르지 않습니다.");
}
if (cue.FadeDuration is < 0 or > 60)
{
throw new PlayoutRequestException("장면 전환 시간이 올바르지 않습니다.");
}
foreach (var field in cue.Fields ?? [])
{
if (string.IsNullOrWhiteSpace(field.ObjectName) ||
field.ObjectName.Length > 128 ||
field.ObjectName.Any(char.IsControl) ||
(field.Value?.Length ?? 0) > 16_384)
{
throw new PlayoutRequestException("장면 필드 값이 올바르지 않습니다.");
}
}
var relativePath = cue.SceneFile?.Trim();
if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath))
{
throw new PlayoutRequestException("장면 파일 이름이 올바르지 않습니다.");
}
if (!string.Equals(relativePath, Path.GetFileName(relativePath), StringComparison.Ordinal) ||
relativePath.Contains("..", StringComparison.Ordinal))
{
throw new PlayoutRequestException("장면 파일 이름이 올바르지 않습니다.");
}
if (!string.Equals(Path.GetExtension(relativePath), ".t2s", StringComparison.OrdinalIgnoreCase))
{
throw new PlayoutRequestException("장면 파일 형식이 올바르지 않습니다.");
}
var fileSceneName = Path.GetFileNameWithoutExtension(relativePath);
if (!string.Equals(fileSceneName, sceneName, StringComparison.OrdinalIgnoreCase))
{
throw new PlayoutRequestException("장면 이름과 장면 파일이 일치하지 않습니다.");
}
if (SceneDirectory is null)
{
throw new PlayoutRequestException("장면 폴더가 설정되지 않았습니다.");
}
var candidate = Path.GetFullPath(Path.Combine(SceneDirectory, relativePath));
var rootPrefix = SceneDirectory.EndsWith(Path.DirectorySeparatorChar)
? SceneDirectory
: SceneDirectory + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) ||
HasReparsePointBetween(SceneDirectory, candidate))
{
throw new PlayoutRequestException("허용된 장면 파일을 찾을 수 없습니다.");
}
return cue with
{
SceneFile = candidate,
SceneName = sceneName!
};
}
private static bool CanMatchProgramTitle(Regex regex)
{
string[] protectedTitles =
[
"PGM",
"PROGRAM",
"Tornado2 PGM",
"Tornado2 PROGRAM",
"PGM OUTPUT",
"PROGRAM OUTPUT"
];
return protectedTitles.Any(regex.IsMatch);
}
private static bool ContainsPathSyntax(string value) =>
value.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':']) >= 0;
private static bool IsSafeSceneCode(string? value)
{
if (string.IsNullOrEmpty(value) || value.Length > 64 || ContainsPathSyntax(value))
{
return false;
}
foreach (var character in value)
{
if (!((character >= 'A' && character <= 'Z') ||
(character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9') ||
character is '_' or '-'))
{
return false;
}
}
return true;
}
private static bool IsLiteralLoopback(string host) =>
string.Equals(host, IPAddress.Loopback.ToString(), StringComparison.Ordinal) ||
string.Equals(host, IPAddress.IPv6Loopback.ToString(), StringComparison.Ordinal);
private static string ValidateSceneDirectory(string? value)
{
value = value?.Trim();
if (string.IsNullOrEmpty(value) || !Path.IsPathFullyQualified(value))
{
throw Invalid(nameof(PlayoutOptions.SceneDirectory));
}
var fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value));
if (!Directory.Exists(fullPath) || IsReparsePoint(fullPath))
{
throw Invalid(nameof(PlayoutOptions.SceneDirectory));
}
return fullPath;
}
private static bool HasReparsePointBetween(string root, string file)
{
if (IsReparsePoint(file))
{
return true;
}
var directory = Path.GetDirectoryName(file);
while (!string.IsNullOrEmpty(directory) &&
!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase))
{
if (IsReparsePoint(directory))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return !string.Equals(directory, root, StringComparison.OrdinalIgnoreCase);
}
private static bool IsReparsePoint(string path)
{
try
{
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
}
catch (IOException)
{
return true;
}
catch (UnauthorizedAccessException)
{
return true;
}
}
private static string RequiredText(string? value, string name)
{
value = value?.Trim();
if (string.IsNullOrEmpty(value) || value.Length > 512)
{
throw Invalid(name);
}
return value;
}
private static void RequireRange(int value, int minimum, int maximum, string name)
{
if (value < minimum || value > maximum)
{
throw Invalid(name);
}
}
private static PlayoutConfigurationException Invalid(string name) =>
new($"송출 설정 {name} 값이 올바르지 않습니다.");
}
internal sealed class PlayoutRequestException : Exception
{
public PlayoutRequestException(string message)
: base(message)
{
}
}

View File

@@ -0,0 +1,352 @@
using System.Globalization;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
namespace MBN_STOCK_WEBVIEW.Playout.Interop;
internal interface IK3dSession : IDisposable
{
bool IsConnected { get; }
void Connect(ValidatedPlayoutOptions options);
void Disconnect();
void Prepare(PlayoutCue cue, int layoutIndex);
void Play(int layoutIndex);
void TakeOut(int layoutIndex, PlayoutTakeOutScope scope);
}
internal interface IK3dSessionFactory
{
IK3dSession Create();
}
internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
{
private readonly ILateBoundComActivator _activator;
public DynamicK3dSessionFactory()
: this(new RegisteredComActivator())
{
}
internal DynamicK3dSessionFactory(ILateBoundComActivator activator)
{
_activator = activator;
}
public IK3dSession Create() => new DynamicK3dSession(_activator);
}
internal interface ILateBoundComActivator
{
object Create(Guid classId);
}
internal sealed class RegisteredComActivator : ILateBoundComActivator
{
public object Create(Guid classId)
{
var type = Type.GetTypeFromCLSID(classId, throwOnError: true)
?? throw new InvalidOperationException("The registered COM type is unavailable.");
return Activator.CreateInstance(type)
?? throw new InvalidOperationException("The registered COM object could not be created.");
}
}
internal sealed class DynamicK3dSession : IK3dSession
{
private readonly ILateBoundComActivator _activator;
private object? _engine;
private object? _eventHandler;
private object? _player;
private object? _scene;
private int? _ownerThreadId;
private bool _disposed;
public DynamicK3dSession(ILateBoundComActivator activator)
{
_activator = activator;
}
public bool IsConnected => _engine is not null && _player is not null;
public void Connect(ValidatedPlayoutOptions options)
{
ArgumentNullException.ThrowIfNull(options);
EnsureThread();
ObjectDisposedException.ThrowIf(_disposed, this);
if (IsConnected)
{
return;
}
try
{
_eventHandler = _activator.Create(K3dComConstants.KaEventHandlerClassGuid);
_engine = _activator.Create(K3dComConstants.KaEngineClassGuid);
var result = Invoke(
_engine,
"KTAPConnect",
options.TcpMode,
options.Host,
options.Port,
options.ClientPort,
_eventHandler);
var hresult = result is null
? 0
: Convert.ToInt32(result, CultureInfo.InvariantCulture);
if (hresult != 1)
{
throw new InvalidOperationException("The K3D connection was rejected.");
}
_player = options.OutputChannel is { } channel
? InvokeRequired(_engine, "GetScenePlayerOnChannel", channel)
: InvokeRequired(_engine, "GetScenePlayer");
}
catch
{
ReleaseAll();
throw;
}
}
public void Disconnect()
{
EnsureThread();
if (_engine is null)
{
ReleaseAll();
return;
}
Exception? failure = null;
try
{
Invoke(_engine, "Disconnect");
}
catch (Exception exception)
{
failure = exception;
}
finally
{
ReleaseAll();
}
if (failure is not null)
{
ExceptionDispatchInfo.Capture(failure).Throw();
}
}
public void Prepare(PlayoutCue cue, int layoutIndex)
{
ArgumentNullException.ThrowIfNull(cue);
EnsureThread();
EnsureConnected();
var engine = _engine!;
var player = _player!;
object? nextScene = null;
var transactionStarted = false;
try
{
nextScene = InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName);
Invoke(
nextScene,
"SetSceneEffectType",
layoutIndex,
K3dComConstants.SceneChangeEffectFade,
cue.FadeDuration);
Invoke(engine, "BeginTransaction");
transactionStarted = true;
foreach (var field in cue.Fields ?? [])
{
ApplyField(nextScene, field);
}
Invoke(nextScene, "QueryVariables");
Invoke(engine, "EndTransaction");
transactionStarted = false;
Invoke(player, "Prepare", layoutIndex, nextScene);
ReleaseComObject(_scene);
_scene = nextScene;
nextScene = null;
}
catch
{
if (transactionStarted)
{
try
{
Invoke(engine, "RollbackTransaction");
}
catch
{
// Preserve the original SDK failure. The engine will recycle this session.
}
}
throw;
}
finally
{
ReleaseComObject(nextScene);
}
}
public void Play(int layoutIndex)
{
EnsureThread();
EnsureConnected();
Invoke(_player!, "Play", layoutIndex);
}
public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope)
{
EnsureThread();
EnsureConnected();
if (scope == PlayoutTakeOutScope.All)
{
Invoke(_player!, "StopAll");
}
else
{
Invoke(_player!, "CutOut", layoutIndex);
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
EnsureThread();
_disposed = true;
try
{
if (_engine is not null)
{
Invoke(_engine, "Disconnect");
}
}
catch
{
// Dispose must still release every RCW on the owning STA.
}
finally
{
ReleaseAll();
}
}
private static void ApplyField(object scene, PlayoutField field)
{
if (string.IsNullOrWhiteSpace(field.ObjectName))
{
throw new InvalidOperationException("A scene object name is required.");
}
var sceneObject = InvokeRequired(scene, "GetObject", field.ObjectName);
try
{
if (field.Value is not null)
{
Invoke(sceneObject, "SetValue", field.Value);
}
if (field.IsVisible is { } visible)
{
Invoke(sceneObject, "SetVisible", visible ? 1 : 0);
}
}
finally
{
ReleaseComObject(sceneObject);
}
}
private void EnsureThread()
{
var currentThreadId = Environment.CurrentManagedThreadId;
if (_ownerThreadId is null)
{
if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA)
{
throw new InvalidOperationException("K3D access requires an STA thread.");
}
_ownerThreadId = currentThreadId;
return;
}
if (_ownerThreadId.Value != currentThreadId)
{
throw new InvalidOperationException("K3D access changed STA threads.");
}
}
private void EnsureConnected()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!IsConnected)
{
throw new InvalidOperationException("The K3D session is not connected.");
}
}
private void ReleaseAll()
{
ReleaseComObject(_scene);
_scene = null;
ReleaseComObject(_player);
_player = null;
ReleaseComObject(_engine);
_engine = null;
ReleaseComObject(_eventHandler);
_eventHandler = null;
}
private static object InvokeRequired(object target, string method, params object?[] arguments) =>
Invoke(target, method, arguments)
?? throw new InvalidOperationException("The K3D operation returned no object.");
private static object? Invoke(object target, string method, params object?[] arguments)
{
try
{
return target.GetType().InvokeMember(
method,
BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
binder: null,
target,
arguments,
CultureInfo.InvariantCulture);
}
catch (TargetInvocationException exception) when (exception.InnerException is not null)
{
ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
throw;
}
}
private static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
}

View File

@@ -0,0 +1,19 @@
namespace MBN_STOCK_WEBVIEW.Playout.Interop;
public static class K3dComConstants
{
public const string TypeLibraryId = "{2B7F2D64-3A8D-401C-BE73-5C0747BA342C}";
public const string TypeLibraryVersion = "1.0";
public const string KaEngineClassId = "{D756CDBE-AA31-42B2-9CC7-018753CA61BF}";
public const string KaEngineProgId = "K3DAsyncEngine.KAEngine.1";
public static readonly Guid KaEngineClassGuid = Guid.Parse(KaEngineClassId);
public const string KaEventHandlerClassId = "{39828C77-EFF0-4E59-979B-8673C028C718}";
public const string KaEventHandlerProgId = "K3DAsyncEngine.KAEventHandler.1";
public static readonly Guid KaEventHandlerClassGuid = Guid.Parse(KaEventHandlerClassId);
public const string ApartmentThreadingModel = "Apartment";
public const int LegacyLayoutIndex = 10;
public const int SceneChangeEffectFade = 7;
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>MBN_STOCK_WEBVIEW.Playout</RootNamespace>
<AssemblyName>MBN_STOCK_WEBVIEW.Playout</AssemblyName>
<Platforms>x64</Platforms>
<PlatformTarget>x64</PlatformTarget>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
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;
public static class PlayoutEngineFactory
{
public static IPlayoutEngine CreateDefault() =>
Create(PlayoutOptionsLoader.Load());
public static IPlayoutEngine Create(PlayoutOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var validated = ValidatedPlayoutOptions.Create(options);
IStaDispatcher? dispatcher = validated.Mode is PlayoutMode.Test or PlayoutMode.Live
? new StaDispatcher(validated.QueueCapacity)
: null;
return new TornadoPlayoutEngine(
validated,
new DynamicK3dSessionFactory(),
new K3dRegistrationProbe(),
new TornadoProcessProbe(),
dispatcher,
new EnvironmentLiveAuthorization(),
TimeProvider.System);
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")]

View File

@@ -0,0 +1,387 @@
using Microsoft.Win32;
using MBN_STOCK_WEBVIEW.Playout.Interop;
namespace MBN_STOCK_WEBVIEW.Playout.Registration;
[Flags]
public enum K3dRegistrationIssue
{
None = 0,
ProcessIsNot64Bit = 1 << 0,
TypeLibraryMissing = 1 << 1,
Win64BinaryMissing = 1 << 2,
Win64BinaryIsNotAmd64 = 1 << 3,
EngineClassMissing = 1 << 4,
EngineProgIdMismatch = 1 << 5,
EngineThreadingModelMismatch = 1 << 6,
EngineTypeLibraryMismatch = 1 << 7,
EventHandlerClassMissing = 1 << 8,
EventHandlerProgIdMismatch = 1 << 9,
EventHandlerThreadingModelMismatch = 1 << 10,
EventHandlerTypeLibraryMismatch = 1 << 11,
EngineProgIdClassIdMismatch = 1 << 12,
EngineServerIsNotAmd64 = 1 << 13,
EventHandlerProgIdClassIdMismatch = 1 << 14,
EventHandlerServerIsNotAmd64 = 1 << 15,
CurrentUserOverridePresent = 1 << 16,
RegistryReadFailed = 1 << 17
}
public sealed record K3dRegistrationReport(
K3dRegistrationIssue Issues,
bool Is64BitProcess,
bool HasWin64TypeLibrary,
bool IsWin64BinaryAmd64,
bool IsEngineClassReady,
bool IsEventHandlerClassReady,
string Message)
{
public bool IsReady => Issues == K3dRegistrationIssue.None;
}
internal interface IK3dRegistrationProbe
{
K3dRegistrationReport Probe();
}
internal sealed record K3dClassRegistrationSnapshot(
bool ClassKeyPresent,
string? ClassProgId,
string? ProgIdClassId,
string? InprocServerPath,
string? ThreadingModel,
string? TypeLibraryId);
internal sealed record K3dRegistrySnapshot(
bool Is64BitProcess,
string? Win64TypeLibraryPath,
K3dClassRegistrationSnapshot Engine,
K3dClassRegistrationSnapshot EventHandler,
bool CurrentUserOverridePresent);
internal interface IK3dRegistrySnapshotSource
{
K3dRegistrySnapshot Capture();
}
internal readonly record struct PeBinaryInspection(bool Exists, bool IsAmd64);
internal interface IPeBinaryInspector
{
PeBinaryInspection Inspect(string? path);
}
public sealed class K3dRegistrationProbe : IK3dRegistrationProbe
{
private readonly IK3dRegistrySnapshotSource _snapshotSource;
private readonly IPeBinaryInspector _binaryInspector;
public K3dRegistrationProbe()
: this(new WindowsK3dRegistrySnapshotSource(), new FilePeBinaryInspector())
{
}
internal K3dRegistrationProbe(
IK3dRegistrySnapshotSource snapshotSource,
IPeBinaryInspector binaryInspector)
{
_snapshotSource = snapshotSource;
_binaryInspector = binaryInspector;
}
public K3dRegistrationReport Probe()
{
try
{
return ProbeCore(_snapshotSource.Capture());
}
catch (Exception)
{
return new K3dRegistrationReport(
K3dRegistrationIssue.RegistryReadFailed,
Environment.Is64BitProcess,
false,
false,
false,
false,
SafeMessage(K3dRegistrationIssue.RegistryReadFailed));
}
}
private K3dRegistrationReport ProbeCore(K3dRegistrySnapshot snapshot)
{
var issues = K3dRegistrationIssue.None;
if (!snapshot.Is64BitProcess)
{
issues |= K3dRegistrationIssue.ProcessIsNot64Bit;
}
var typeLibraryBinary = _binaryInspector.Inspect(snapshot.Win64TypeLibraryPath);
var hasWin64TypeLibrary = !string.IsNullOrWhiteSpace(snapshot.Win64TypeLibraryPath);
if (!hasWin64TypeLibrary)
{
issues |= K3dRegistrationIssue.TypeLibraryMissing;
}
if (!typeLibraryBinary.Exists)
{
issues |= K3dRegistrationIssue.Win64BinaryMissing;
}
else if (!typeLibraryBinary.IsAmd64)
{
issues |= K3dRegistrationIssue.Win64BinaryIsNotAmd64;
}
var engineReady = CheckClass(
snapshot.Engine,
K3dComConstants.KaEngineClassGuid,
K3dComConstants.KaEngineProgId,
K3dRegistrationIssue.EngineClassMissing,
K3dRegistrationIssue.EngineProgIdMismatch,
K3dRegistrationIssue.EngineProgIdClassIdMismatch,
K3dRegistrationIssue.EngineServerIsNotAmd64,
K3dRegistrationIssue.EngineThreadingModelMismatch,
K3dRegistrationIssue.EngineTypeLibraryMismatch,
ref issues);
var eventHandlerReady = CheckClass(
snapshot.EventHandler,
K3dComConstants.KaEventHandlerClassGuid,
K3dComConstants.KaEventHandlerProgId,
K3dRegistrationIssue.EventHandlerClassMissing,
K3dRegistrationIssue.EventHandlerProgIdMismatch,
K3dRegistrationIssue.EventHandlerProgIdClassIdMismatch,
K3dRegistrationIssue.EventHandlerServerIsNotAmd64,
K3dRegistrationIssue.EventHandlerThreadingModelMismatch,
K3dRegistrationIssue.EventHandlerTypeLibraryMismatch,
ref issues);
if (snapshot.CurrentUserOverridePresent)
{
issues |= K3dRegistrationIssue.CurrentUserOverridePresent;
engineReady = false;
eventHandlerReady = false;
}
return new K3dRegistrationReport(
issues,
snapshot.Is64BitProcess,
hasWin64TypeLibrary,
typeLibraryBinary.Exists && typeLibraryBinary.IsAmd64,
engineReady,
eventHandlerReady,
SafeMessage(issues));
}
private bool CheckClass(
K3dClassRegistrationSnapshot registration,
Guid expectedClassId,
string expectedProgId,
K3dRegistrationIssue missingIssue,
K3dRegistrationIssue classProgIdIssue,
K3dRegistrationIssue progIdClassIdIssue,
K3dRegistrationIssue architectureIssue,
K3dRegistrationIssue threadingIssue,
K3dRegistrationIssue typeLibraryIssue,
ref K3dRegistrationIssue issues)
{
if (!registration.ClassKeyPresent)
{
issues |= missingIssue;
}
if (!string.Equals(
registration.ClassProgId,
expectedProgId,
StringComparison.OrdinalIgnoreCase))
{
issues |= classProgIdIssue;
}
if (!MatchesGuid(registration.ProgIdClassId, expectedClassId))
{
issues |= progIdClassIdIssue;
}
var classBinary = _binaryInspector.Inspect(registration.InprocServerPath);
if (!classBinary.Exists)
{
issues |= missingIssue;
}
else if (!classBinary.IsAmd64)
{
issues |= architectureIssue;
}
if (!string.Equals(
registration.ThreadingModel,
K3dComConstants.ApartmentThreadingModel,
StringComparison.OrdinalIgnoreCase))
{
issues |= threadingIssue;
}
if (!MatchesGuid(registration.TypeLibraryId, Guid.Parse(K3dComConstants.TypeLibraryId)))
{
issues |= typeLibraryIssue;
}
var classIssues =
missingIssue |
classProgIdIssue |
progIdClassIdIssue |
architectureIssue |
threadingIssue |
typeLibraryIssue;
return (issues & classIssues) == 0;
}
private static bool MatchesGuid(string? value, Guid expected) =>
Guid.TryParse(value, out var actual) && actual == expected;
private static string SafeMessage(K3dRegistrationIssue issues) => issues switch
{
K3dRegistrationIssue.None => "x64 K3D 송출 구성 요소가 준비되었습니다.",
_ when issues.HasFlag(K3dRegistrationIssue.CurrentUserOverridePresent) =>
"사용자별 K3D 등록 재정의가 감지되어 송출 연결을 차단했습니다.",
_ when issues.HasFlag(K3dRegistrationIssue.RegistryReadFailed) =>
"K3D 송출 구성 요소 등록을 확인할 수 없습니다.",
_ when issues.HasFlag(K3dRegistrationIssue.ProcessIsNot64Bit) =>
"송출 어댑터를 x64 프로세스로 실행해야 합니다.",
_ when issues.HasFlag(K3dRegistrationIssue.TypeLibraryMissing) ||
issues.HasFlag(K3dRegistrationIssue.Win64BinaryMissing) ||
issues.HasFlag(K3dRegistrationIssue.Win64BinaryIsNotAmd64) ||
issues.HasFlag(K3dRegistrationIssue.EngineServerIsNotAmd64) ||
issues.HasFlag(K3dRegistrationIssue.EventHandlerServerIsNotAmd64) =>
"x64 K3D 송출 구성 요소를 확인할 수 없습니다.",
_ => "K3D 송출 구성 요소 등록이 올바르지 않습니다."
};
}
internal sealed class WindowsK3dRegistrySnapshotSource : IK3dRegistrySnapshotSource
{
internal static IReadOnlyList<string> CurrentUserOverridePaths { get; } = Array.AsReadOnly(
[
$"CLSID\\{K3dComConstants.KaEngineClassId}",
$"CLSID\\{K3dComConstants.KaEventHandlerClassId}",
K3dComConstants.KaEngineProgId,
K3dComConstants.KaEventHandlerProgId,
$"TypeLib\\{K3dComConstants.TypeLibraryId}"
]);
public K3dRegistrySnapshot Capture()
{
using var localMachineBase = RegistryKey.OpenBaseKey(
RegistryHive.LocalMachine,
RegistryView.Registry64);
using var localMachineClasses = localMachineBase.OpenSubKey("SOFTWARE\\Classes");
using var currentUserBase = RegistryKey.OpenBaseKey(
RegistryHive.CurrentUser,
RegistryView.Registry64);
using var currentUserClasses = currentUserBase.OpenSubKey("Software\\Classes");
return new K3dRegistrySnapshot(
Environment.Is64BitProcess,
ReadDefault(
localMachineClasses,
$"TypeLib\\{K3dComConstants.TypeLibraryId}\\{K3dComConstants.TypeLibraryVersion}\\0\\win64"),
ReadClass(
localMachineClasses,
K3dComConstants.KaEngineClassId,
K3dComConstants.KaEngineProgId),
ReadClass(
localMachineClasses,
K3dComConstants.KaEventHandlerClassId,
K3dComConstants.KaEventHandlerProgId),
HasCurrentUserOverride(currentUserClasses));
}
private static K3dClassRegistrationSnapshot ReadClass(
RegistryKey? classes,
string classId,
string progId)
{
using var classKey = classes?.OpenSubKey($"CLSID\\{classId}");
using var classProgId = classKey?.OpenSubKey("ProgID");
using var inprocServer = classKey?.OpenSubKey("InprocServer32");
using var typeLibrary = classKey?.OpenSubKey("TypeLib");
using var progIdClass = classes?.OpenSubKey($"{progId}\\CLSID");
return new K3dClassRegistrationSnapshot(
classKey is not null,
classProgId?.GetValue(null) as string,
progIdClass?.GetValue(null) as string,
inprocServer?.GetValue(null) as string,
inprocServer?.GetValue("ThreadingModel") as string,
typeLibrary?.GetValue(null) as string);
}
private static string? ReadDefault(RegistryKey? root, string relativePath)
{
using var key = root?.OpenSubKey(relativePath);
return key?.GetValue(null) as string;
}
private static bool HasCurrentUserOverride(RegistryKey? currentUserClasses)
{
foreach (var path in CurrentUserOverridePaths)
{
using var key = currentUserClasses?.OpenSubKey(path);
if (key is not null)
{
return true;
}
}
return false;
}
}
internal sealed class FilePeBinaryInspector : IPeBinaryInspector
{
private const ushort ImageFileMachineAmd64 = 0x8664;
private const uint PortableExecutableSignature = 0x00004550;
public PeBinaryInspection Inspect(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return new PeBinaryInspection(false, false);
}
try
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
using var reader = new BinaryReader(stream);
if (stream.Length < 64)
{
return new PeBinaryInspection(true, false);
}
stream.Position = 0x3c;
var headerOffset = reader.ReadInt32();
if (headerOffset < 0 || headerOffset > stream.Length - 6)
{
return new PeBinaryInspection(true, false);
}
stream.Position = headerOffset;
var isAmd64 = reader.ReadUInt32() == PortableExecutableSignature &&
reader.ReadUInt16() == ImageFileMachineAmd64;
return new PeBinaryInspection(true, isAmd64);
}
catch (IOException)
{
return new PeBinaryInspection(true, false);
}
catch (UnauthorizedAccessException)
{
return new PeBinaryInspection(true, false);
}
}
}

View File

@@ -0,0 +1,422 @@
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
namespace MBN_STOCK_WEBVIEW.Playout.Runtime;
internal interface IStaDispatcher : IAsyncDisposable
{
bool IsQuarantined { get; }
int? ManagedThreadId { get; }
Task<T> InvokeAsync<T>(
Func<T> callback,
TimeSpan timeout,
CancellationToken cancellationToken,
Action<T>? abandonedResultCleanup = null,
Action? abandonedFailureCleanup = null);
}
internal interface IStaDispatcherFactory
{
IStaDispatcher Create(int capacity);
}
internal sealed class StaDispatcherFactory : IStaDispatcherFactory
{
public IStaDispatcher Create(int capacity) => new StaDispatcher(capacity);
}
internal sealed class StaDispatcher : IStaDispatcher
{
private const uint QsAllInput = 0x04ff;
private const uint MwmoInputAvailable = 0x0004;
private const uint PmRemove = 0x0001;
private const uint WaitObject0 = 0;
private const uint WaitFailed = 0xffffffff;
private readonly ConcurrentQueue<StaWorkItem> _queue = new();
private readonly SemaphoreSlim _slots;
private readonly AutoResetEvent _workAvailable = new(false);
private readonly Thread _thread;
private int _state;
private int _managedThreadId;
public StaDispatcher(int capacity)
{
if (capacity < 1)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
_slots = new SemaphoreSlim(capacity, capacity);
_thread = new Thread(ThreadMain)
{
IsBackground = true,
Name = "MBN K3D STA Dispatcher"
};
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
}
public bool IsQuarantined => Volatile.Read(ref _state) == 1;
public int? ManagedThreadId
{
get
{
var value = Volatile.Read(ref _managedThreadId);
return value == 0 ? null : value;
}
}
public async Task<T> InvokeAsync<T>(
Func<T> callback,
TimeSpan timeout,
CancellationToken cancellationToken,
Action<T>? abandonedResultCleanup = null,
Action? abandonedFailureCleanup = null)
{
ArgumentNullException.ThrowIfNull(callback);
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout));
}
ThrowIfUnavailable();
await _slots.WaitAsync(cancellationToken).ConfigureAwait(false);
var item = new StaWorkItem<T>(
callback,
abandonedResultCleanup,
abandonedFailureCleanup);
var queued = false;
try
{
ThrowIfUnavailable();
using var registration = cancellationToken.Register(
static state => ((StaWorkItem)state!).CancelBeforeExecution(),
item);
_queue.Enqueue(item);
queued = true;
_workAvailable.Set();
var completion = item.Completion;
var timeoutTask = Task.Delay(timeout);
var completed = await Task.WhenAny(completion, timeoutTask).ConfigureAwait(false);
if (completed == completion || completion.IsCompleted)
{
return await completion.ConfigureAwait(false);
}
if (item.TryTimeout())
{
Quarantine();
}
return await completion.ConfigureAwait(false);
}
catch
{
if (!queued)
{
_slots.Release();
}
throw;
}
}
public ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _state, 2) == 2)
{
return ValueTask.CompletedTask;
}
FailQueuedItems(new ObjectDisposedException(nameof(StaDispatcher)));
_workAvailable.Set();
if (Thread.CurrentThread != _thread)
{
_thread.Join(TimeSpan.FromSeconds(2));
}
_slots.Dispose();
_workAvailable.Dispose();
return ValueTask.CompletedTask;
}
private void ThreadMain()
{
Volatile.Write(ref _managedThreadId, Environment.CurrentManagedThreadId);
var waitHandles = new[] { _workAvailable.SafeWaitHandle.DangerousGetHandle() };
while (Volatile.Read(ref _state) != 2)
{
PumpMessages();
if (_queue.TryDequeue(out var item))
{
_slots.Release();
if (Volatile.Read(ref _state) == 0)
{
item.Execute();
}
else
{
item.Fail(new StaDispatcherQuarantinedException());
}
continue;
}
var waitResult = MsgWaitForMultipleObjectsEx(
1,
waitHandles,
250,
QsAllInput,
MwmoInputAvailable);
if (waitResult == WaitFailed)
{
Quarantine();
}
else if (waitResult == WaitObject0 + 1)
{
PumpMessages();
}
}
PumpMessages();
}
private void Quarantine()
{
if (Interlocked.CompareExchange(ref _state, 1, 0) != 0)
{
return;
}
FailQueuedItems(new StaDispatcherQuarantinedException());
_workAvailable.Set();
}
private void FailQueuedItems(Exception exception)
{
while (_queue.TryDequeue(out var queued))
{
_slots.Release();
queued.Fail(exception);
}
}
private void ThrowIfUnavailable()
{
var state = Volatile.Read(ref _state);
if (state == 1)
{
throw new StaDispatcherQuarantinedException();
}
ObjectDisposedException.ThrowIf(state == 2, this);
}
private static void PumpMessages()
{
while (PeekMessage(out var message, IntPtr.Zero, 0, 0, PmRemove))
{
TranslateMessage(in message);
DispatchMessage(in message);
}
}
[DllImport("user32.dll", SetLastError = true)]
private static extern uint MsgWaitForMultipleObjectsEx(
uint count,
[In] IntPtr[] handles,
uint milliseconds,
uint wakeMask,
uint flags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PeekMessage(
out NativeMessage message,
IntPtr window,
uint filterMinimum,
uint filterMaximum,
uint removeMessage);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TranslateMessage(in NativeMessage message);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(in NativeMessage message);
[StructLayout(LayoutKind.Sequential)]
private struct NativeMessage
{
public IntPtr Window;
public uint Message;
public UIntPtr WParam;
public IntPtr LParam;
public uint Time;
public NativePoint Point;
public uint Private;
}
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X;
public int Y;
}
private abstract class StaWorkItem
{
// 0 queued, 1 running, 2 completed/cancelled/timed out
private int _executionState;
public void Execute()
{
if (Interlocked.CompareExchange(ref _executionState, 1, 0) != 0)
{
return;
}
ExecuteCore();
}
public void CancelBeforeExecution()
{
if (Interlocked.CompareExchange(ref _executionState, 2, 0) == 0)
{
CancelCore();
}
}
public bool TryTimeout()
{
while (true)
{
var state = Volatile.Read(ref _executionState);
if (state == 2)
{
return false;
}
if (Interlocked.CompareExchange(ref _executionState, 2, state) == state)
{
TimeoutCore();
return true;
}
}
}
public void Fail(Exception exception)
{
if (Interlocked.Exchange(ref _executionState, 2) != 2)
{
FailCore(exception);
}
}
protected bool TryCompleteExecution() =>
Interlocked.CompareExchange(ref _executionState, 2, 1) == 1;
protected abstract void ExecuteCore();
protected abstract void CancelCore();
protected abstract void TimeoutCore();
protected abstract void FailCore(Exception exception);
}
private sealed class StaWorkItem<T> : StaWorkItem
{
private readonly Func<T> _callback;
private readonly Action<T>? _abandonedResultCleanup;
private readonly Action? _abandonedFailureCleanup;
private readonly TaskCompletionSource<T> _completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public StaWorkItem(
Func<T> callback,
Action<T>? abandonedResultCleanup,
Action? abandonedFailureCleanup)
{
_callback = callback;
_abandonedResultCleanup = abandonedResultCleanup;
_abandonedFailureCleanup = abandonedFailureCleanup;
}
public Task<T> Completion => _completion.Task;
protected override void ExecuteCore()
{
try
{
var result = _callback();
if (TryCompleteExecution())
{
_completion.TrySetResult(result);
}
else
{
try
{
_abandonedResultCleanup?.Invoke(result);
}
catch
{
// Late cleanup is best effort and must not terminate the STA pump.
}
}
}
catch (Exception exception)
{
if (TryCompleteExecution())
{
_completion.TrySetException(exception);
}
else
{
try
{
_abandonedFailureCleanup?.Invoke();
}
catch
{
// Late cleanup is best effort and must not terminate the STA pump.
}
}
}
}
protected override void CancelCore() =>
_completion.TrySetCanceled();
protected override void TimeoutCore() =>
_completion.TrySetException(new StaOperationTimedOutException());
protected override void FailCore(Exception exception) =>
_completion.TrySetException(exception);
}
}
internal sealed class StaOperationTimedOutException : TimeoutException
{
public StaOperationTimedOutException()
: base("The STA operation timed out and its outcome is unknown.")
{
}
}
internal sealed class StaDispatcherQuarantinedException : InvalidOperationException
{
public StaDispatcherQuarantinedException()
: base("The STA dispatcher is quarantined.")
{
}
}

View File

@@ -0,0 +1,125 @@
using System.Diagnostics;
using System.ComponentModel;
using System.Text.RegularExpressions;
namespace MBN_STOCK_WEBVIEW.Playout.Runtime;
internal sealed record TornadoProcessSnapshot(
int TotalProcessCount,
int EligibleProcessCount,
int ProgramProcessCount)
{
public bool AnyTornadoProcess => TotalProcessCount > 0;
public bool HasSingleEligibleProcess => EligibleProcessCount == 1;
public bool UnsafeProgramWindowMatched => ProgramProcessCount > 0;
}
internal interface ITornadoProcessProbe
{
TornadoProcessSnapshot Capture(Regex? testWindowPattern);
}
internal sealed class TornadoProcessProbe : ITornadoProcessProbe
{
internal const string ProcessNamePrefix = "Tornado2";
public TornadoProcessSnapshot Capture(Regex? testWindowPattern)
{
var totalCount = 0;
var eligibleCount = 0;
var programCount = 0;
Process[] processes;
try
{
processes = Process.GetProcesses();
}
catch (Exception exception) when (IsProcessInspectionException(exception))
{
return new TornadoProcessSnapshot(0, 0, 0);
}
foreach (var process in processes)
{
try
{
string processName;
try
{
processName = process.ProcessName;
}
catch (Exception exception) when (IsProcessInspectionException(exception))
{
continue;
}
if (!processName.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
totalCount++;
if (testWindowPattern is null)
{
eligibleCount++;
continue;
}
string title;
try
{
title = process.MainWindowTitle;
}
catch (Exception exception) when (IsProcessInspectionException(exception))
{
continue;
}
if (IsProgramTitle(title))
{
programCount++;
continue;
}
bool titleMatches;
try
{
titleMatches = testWindowPattern.IsMatch(title);
}
catch (RegexMatchTimeoutException)
{
titleMatches = false;
}
if (!titleMatches)
{
continue;
}
eligibleCount++;
}
finally
{
process.Dispose();
}
}
return new TornadoProcessSnapshot(totalCount, eligibleCount, programCount);
}
internal static bool IsTornadoProcessName(string? processName) =>
processName?.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) == true;
internal static bool IsProgramTitle(string? title) =>
!string.IsNullOrWhiteSpace(title) &&
(title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase));
private static bool IsProcessInspectionException(Exception exception) =>
exception is InvalidOperationException or
Win32Exception or
NotSupportedException or
UnauthorizedAccessException;
}

View File

@@ -0,0 +1,18 @@
namespace MBN_STOCK_WEBVIEW.Playout.Safety;
internal interface ILiveAuthorization
{
bool IsAuthorizedForThisLaunch { get; }
}
internal sealed class EnvironmentLiveAuthorization : ILiveAuthorization
{
public EnvironmentLiveAuthorization()
{
// Capture once. Changing a process environment variable after launch cannot arm output.
IsAuthorizedForThisLaunch =
Configuration.PlayoutOptionsLoader.IsLiveAuthorizedForThisLaunch();
}
public bool IsAuthorizedForThisLaunch { get; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
using Xunit;
[assembly: CollectionBehavior(DisableTestParallelization = true)]

View File

@@ -0,0 +1,152 @@
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class DryRunPlayoutEngineTests
{
[Fact]
public async Task DefaultOptions_AreDryRunAndNeverReportARealConnection()
{
await using var engine = CreateEngine(PlayoutMode.DryRun);
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutMode.DryRun, engine.Status.Mode);
Assert.Equal(PlayoutConnectionState.DryRunReady, engine.Status.State);
Assert.True(engine.Status.IsCommandAvailable);
Assert.False(engine.Status.IsConnected);
Assert.True(result.IsSuccess);
Assert.True(result.IsDryRun);
}
[Fact]
public async Task DisabledMode_RejectsEveryOperationWithoutChangingState()
{
await using var engine = CreateEngine(PlayoutMode.Disabled);
var cue = Cue("disabled-scene");
var results = new[]
{
await engine.ConnectAsync(CancellationToken.None),
await engine.PrepareAsync(cue, CancellationToken.None),
await engine.TakeInAsync(CancellationToken.None),
await engine.NextAsync(cue, CancellationToken.None),
await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None),
await engine.DisconnectAsync(CancellationToken.None)
};
Assert.All(results, result => Assert.Equal(PlayoutResultCode.Rejected, result.Code));
Assert.All(results, result => Assert.False(result.IsDryRun));
Assert.Equal(PlayoutConnectionState.Disabled, engine.Status.State);
Assert.False(engine.Status.IsConnected);
Assert.False(engine.Status.IsCommandAvailable);
Assert.Null(engine.Status.PreparedSceneName);
Assert.Null(engine.Status.OnAirSceneName);
}
[Fact]
public async Task DryRun_TransitionsPreparedAndOnAirSceneStateInCommandOrder()
{
await using var engine = CreateEngine(PlayoutMode.DryRun);
var statuses = new List<PlayoutStatus>();
engine.StatusChanged += (_, args) => statuses.Add(args.Current);
var connect = await engine.ConnectAsync(CancellationToken.None);
var prepare = await engine.PrepareAsync(
Cue("first-scene"),
CancellationToken.None);
Assert.Equal("first-scene", engine.Status.PreparedSceneName);
Assert.Null(engine.Status.OnAirSceneName);
var takeIn = await engine.TakeInAsync(CancellationToken.None);
Assert.Null(engine.Status.PreparedSceneName);
Assert.Equal("first-scene", engine.Status.OnAirSceneName);
var next = await engine.NextAsync(
Cue("next-scene"),
CancellationToken.None);
Assert.Null(engine.Status.PreparedSceneName);
Assert.Equal("next-scene", engine.Status.OnAirSceneName);
var takeOut = await engine.TakeOutAsync(
PlayoutTakeOutScope.Layout,
CancellationToken.None);
Assert.Null(engine.Status.OnAirSceneName);
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
Assert.All(
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
result => Assert.Equal(PlayoutResultCode.Success, result.Code));
Assert.All(
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
result => Assert.True(result.IsDryRun));
Assert.Equal(6, statuses.Count);
Assert.True(statuses.Zip(statuses.Skip(1), (left, right) => left.Sequence < right.Sequence).All(x => x));
Assert.All(statuses, status => Assert.Equal(PlayoutConnectionState.DryRunReady, status.State));
}
[Fact]
public async Task DryRun_TakeInWithoutPreparedSceneIsRejected()
{
await using var engine = CreateEngine(PlayoutMode.DryRun);
var result = await engine.TakeInAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
Assert.Null(engine.Status.OnAirSceneName);
}
[Fact]
public async Task CancelledDryRunCommand_DoesNotChangeStatus()
{
await using var engine = CreateEngine(PlayoutMode.DryRun);
var initialStatus = engine.Status;
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var result = await engine.PrepareAsync(Cue("cancelled-scene"), cancellation.Token);
Assert.Equal(PlayoutResultCode.Cancelled, result.Code);
Assert.Same(initialStatus, engine.Status);
Assert.Null(engine.Status.PreparedSceneName);
}
[Fact]
public async Task DryRun_PublicStatusAndResultDoNotExposeScenePath()
{
const string secretPath = "C:\\private-vendor-assets\\do-not-log\\secret.t2s";
await using var engine = CreateEngine(PlayoutMode.DryRun);
var cue = new PlayoutCue(secretPath, "C:\\unsafe-scene-name");
var result = await engine.PrepareAsync(cue, CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.Equal("장면", engine.Status.PreparedSceneName);
Assert.DoesNotContain(secretPath, result.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(secretPath, engine.Status.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(cue.SceneName, result.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(cue.SceneName, engine.Status.Message, StringComparison.OrdinalIgnoreCase);
}
private static PlayoutCue Cue(string sceneName) => new(
$"C:\\test-only\\{sceneName}.t2s",
sceneName,
[new PlayoutField("headline", "safe value", true)]);
private static TornadoPlayoutEngine CreateEngine(PlayoutMode mode)
{
var options = ValidatedPlayoutOptions.Create(new PlayoutOptions { Mode = mode });
return new TornadoPlayoutEngine(
options,
new FakeK3dSessionFactory(),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(0, 0, 0)),
null,
new FakeLiveAuthorization(false),
TimeProvider.System,
startMonitor: false);
}
}

View File

@@ -0,0 +1,280 @@
using System.Collections.Concurrent;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class DynamicK3dSessionTests
{
[Theory]
[InlineData(0)]
[InlineData(-1)]
public async Task Connect_WhenKtapDoesNotReturnOne_FailsClosed(int connectResult)
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var log = new FakeComLog();
var engine = new FakeEngine(
log,
new FakePlayer(log),
new FakeScene(log),
connectResult);
var activator = new FakeActivator(log, engine);
await using var dispatcher = new StaDispatcher(capacity: 1);
var exception = await Assert.ThrowsAnyAsync<Exception>(
() => dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None));
Assert.DoesNotContain(options.Host, exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(connectResult.ToString(), exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain("GetScenePlayer", log.Names);
}
[Theory]
[InlineData(PlayoutTakeOutScope.All, "StopAll")]
[InlineData(PlayoutTakeOutScope.Layout, "CutOut:10")]
public async Task FakeLateBoundCom_UsesLegacyCallOrderOnOneStaThread(
PlayoutTakeOutScope takeOutScope,
string expectedTakeOutCall)
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var cue = options.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
[
new PlayoutField("headline", "safe value", true),
new PlayoutField("badge", null, false)
],
FadeDuration: 12));
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene = new FakeScene(log);
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
await using var dispatcher = new StaDispatcher(capacity: 4);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
session.Prepare(cue, options.LayoutIndex);
session.Play(options.LayoutIndex);
session.TakeOut(options.LayoutIndex, takeOutScope);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(
new[]
{
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
"KTAPConnect:1:127.0.0.1:30001:0",
"GetScenePlayerOnChannel:9",
"LoadScene:test-scene",
"SetSceneEffectType:10:7:12",
"BeginTransaction",
"GetObject:headline",
"SetValue:headline:safe value",
"SetVisible:headline:1",
"GetObject:badge",
"SetVisible:badge:0",
"QueryVariables",
"EndTransaction",
"Prepare:10",
"Play:10",
expectedTakeOutCall,
"Disconnect"
},
log.Names);
Assert.Single(log.ThreadIds.Distinct());
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
}
[Fact]
public void LateBoundActivatorContract_AcceptsOnlyAClsid()
{
var create = typeof(ILateBoundComActivator).GetMethod(nameof(ILateBoundComActivator.Create));
Assert.NotNull(create);
Assert.Equal(typeof(Guid), Assert.Single(create.GetParameters()).ParameterType);
Assert.Equal(typeof(object), create.ReturnType);
}
private static PlayoutOptions TestOptions(string sceneDirectory) => new()
{
Mode = PlayoutMode.Test,
SceneDirectory = sceneDirectory,
OutputChannel = 9,
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
TestSceneAllowlist = ["test-scene"]
};
public sealed class FakeComLog
{
private readonly ConcurrentQueue<string> _names = new();
private readonly ConcurrentQueue<int> _threadIds = new();
private readonly ConcurrentQueue<ApartmentState> _apartmentStates = new();
public string[] Names => _names.ToArray();
public int[] ThreadIds => _threadIds.ToArray();
public ApartmentState[] ApartmentStates => _apartmentStates.ToArray();
public void Add(string name)
{
_names.Enqueue(name);
_threadIds.Enqueue(Environment.CurrentManagedThreadId);
_apartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState());
}
}
private sealed class FakeActivator : ILateBoundComActivator
{
private readonly FakeComLog _log;
private readonly FakeEngine _engine;
private readonly object _eventHandler = new();
public FakeActivator(FakeComLog log, FakeEngine engine)
{
_log = log;
_engine = engine;
}
public object Create(Guid classId)
{
_log.Add($"Create:{classId:B}");
return classId switch
{
var value when value == K3dComConstants.KaEventHandlerClassGuid => _eventHandler,
var value when value == K3dComConstants.KaEngineClassGuid => _engine,
_ => throw new InvalidOperationException("Unexpected fake CLSID.")
};
}
}
public sealed class FakeEngine
{
private readonly FakeComLog _log;
private readonly FakePlayer _player;
private readonly FakeScene _scene;
private readonly int _connectResult;
public FakeEngine(
FakeComLog log,
FakePlayer player,
FakeScene scene,
int connectResult = 1)
{
_log = log;
_player = player;
_scene = scene;
_connectResult = connectResult;
}
public int KTAPConnect(
int tcpMode,
string host,
int hostPort,
int clientPort,
object eventHandler)
{
Assert.NotNull(eventHandler);
_log.Add($"KTAPConnect:{tcpMode}:{host}:{hostPort}:{clientPort}");
return _connectResult;
}
public object GetScenePlayerOnChannel(int channel)
{
_log.Add($"GetScenePlayerOnChannel:{channel}");
return _player;
}
public object LoadScene(string sceneFile, string sceneName)
{
Assert.True(System.IO.Path.IsPathFullyQualified(sceneFile));
_log.Add($"LoadScene:{sceneName}");
return _scene;
}
public void BeginTransaction() => _log.Add("BeginTransaction");
public void EndTransaction() => _log.Add("EndTransaction");
public void Disconnect() => _log.Add("Disconnect");
}
public sealed class FakePlayer
{
private readonly FakeComLog _log;
public FakePlayer(FakeComLog log)
{
_log = log;
}
public void Prepare(int layoutIndex, object scene)
{
Assert.NotNull(scene);
_log.Add($"Prepare:{layoutIndex}");
}
public void Play(int layoutIndex) => _log.Add($"Play:{layoutIndex}");
public void StopAll() => _log.Add("StopAll");
public void CutOut(int layoutIndex) => _log.Add($"CutOut:{layoutIndex}");
}
public sealed class FakeScene
{
private readonly FakeComLog _log;
public FakeScene(FakeComLog log)
{
_log = log;
}
public void SetSceneEffectType(int layoutIndex, int effectType, int duration) =>
_log.Add($"SetSceneEffectType:{layoutIndex}:{effectType}:{duration}");
public object GetObject(string objectName)
{
_log.Add($"GetObject:{objectName}");
return new FakeSceneObject(_log, objectName);
}
public void QueryVariables() => _log.Add("QueryVariables");
}
public sealed class FakeSceneObject
{
private readonly FakeComLog _log;
private readonly string _name;
public FakeSceneObject(FakeComLog log, string name)
{
_log = log;
_name = name;
}
public void SetValue(string value) => _log.Add($"SetValue:{_name}:{value}");
public void SetVisible(int visible) => _log.Add($"SetVisible:{_name}:{visible}");
}
}

View File

@@ -0,0 +1,4 @@
global using MBN_STOCK_WEBVIEW.Core.Playout;
global using MBN_STOCK_WEBVIEW.Playout;
global using MBN_STOCK_WEBVIEW.Playout.Configuration;
global using Xunit;

View File

@@ -0,0 +1,204 @@
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Registration;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class K3dRegistrationProbeTests
{
private const string TypeLibraryPath = "C:\\vendor-secret\\typelib-x64.dll";
private const string EnginePath = "C:\\vendor-secret\\engine-x64.dll";
private const string EventHandlerPath = "C:\\vendor-secret\\events-x64.dll";
[Fact]
public void Probe_WithReciprocalAmd64MachineRegistration_IsReady()
{
var binaries = ReadyBinaries();
var probe = CreateProbe(ReadySnapshot(), binaries);
var report = probe.Probe();
Assert.True(report.IsReady);
Assert.Equal(K3dRegistrationIssue.None, report.Issues);
Assert.True(report.IsEngineClassReady);
Assert.True(report.IsEventHandlerClassReady);
Assert.Equal(
new[] { TypeLibraryPath, EnginePath, EventHandlerPath },
binaries.InspectedPaths);
}
[Fact]
public void Probe_WhenEitherDirectionOfReciprocalMappingDiffers_FailsClosed()
{
var snapshot = ReadySnapshot() with
{
Engine = ReadySnapshot().Engine with
{
ClassProgId = "Unexpected.Engine.1",
ProgIdClassId = K3dComConstants.KaEventHandlerClassId
},
EventHandler = ReadySnapshot().EventHandler with
{
ProgIdClassId = K3dComConstants.KaEngineClassId
}
};
var probe = CreateProbe(snapshot, ReadyBinaries());
var report = probe.Probe();
Assert.False(report.IsReady);
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineProgIdMismatch));
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineProgIdClassIdMismatch));
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerProgIdClassIdMismatch));
Assert.False(report.IsEngineClassReady);
Assert.False(report.IsEventHandlerClassReady);
AssertSafe(report);
}
[Fact]
public void Probe_WhenEitherClassServerIsNotAmd64_FailsClosed()
{
var binaries = ReadyBinaries();
binaries.Set(EnginePath, exists: true, isAmd64: false);
binaries.Set(EventHandlerPath, exists: true, isAmd64: false);
var probe = CreateProbe(ReadySnapshot(), binaries);
var report = probe.Probe();
Assert.False(report.IsReady);
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineServerIsNotAmd64));
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerServerIsNotAmd64));
Assert.False(report.IsEngineClassReady);
Assert.False(report.IsEventHandlerClassReady);
AssertSafe(report);
}
[Fact]
public void Probe_WhenCurrentUserOverrideExists_FailsClosedWithoutDetails()
{
var probe = CreateProbe(
ReadySnapshot() with { CurrentUserOverridePresent = true },
ReadyBinaries());
var report = probe.Probe();
Assert.False(report.IsReady);
Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.CurrentUserOverridePresent));
Assert.False(report.IsEngineClassReady);
Assert.False(report.IsEventHandlerClassReady);
Assert.Contains("재정의", report.Message, StringComparison.Ordinal);
AssertSafe(report);
}
[Fact]
public void CurrentUserOverrideBoundary_CoversBothClassesProgIdsAndTypeLibrary()
{
Assert.Equal(
new[]
{
$"CLSID\\{K3dComConstants.KaEngineClassId}",
$"CLSID\\{K3dComConstants.KaEventHandlerClassId}",
K3dComConstants.KaEngineProgId,
K3dComConstants.KaEventHandlerProgId,
$"TypeLib\\{K3dComConstants.TypeLibraryId}"
},
WindowsK3dRegistrySnapshotSource.CurrentUserOverridePaths);
}
[Fact]
public void Probe_WhenRegistrySnapshotCannotBeRead_ReturnsSafeFailure()
{
var probe = new K3dRegistrationProbe(
new ThrowingSnapshotSource(),
ReadyBinaries());
var report = probe.Probe();
Assert.False(report.IsReady);
Assert.Equal(K3dRegistrationIssue.RegistryReadFailed, report.Issues);
AssertSafe(report);
}
private static K3dRegistrationProbe CreateProbe(
K3dRegistrySnapshot snapshot,
FakeBinaryInspector binaries) =>
new(new FakeSnapshotSource(snapshot), binaries);
private static K3dRegistrySnapshot ReadySnapshot() => new(
Is64BitProcess: true,
Win64TypeLibraryPath: TypeLibraryPath,
Engine: new K3dClassRegistrationSnapshot(
ClassKeyPresent: true,
ClassProgId: K3dComConstants.KaEngineProgId,
ProgIdClassId: K3dComConstants.KaEngineClassId,
InprocServerPath: EnginePath,
ThreadingModel: K3dComConstants.ApartmentThreadingModel,
TypeLibraryId: K3dComConstants.TypeLibraryId),
EventHandler: new K3dClassRegistrationSnapshot(
ClassKeyPresent: true,
ClassProgId: K3dComConstants.KaEventHandlerProgId,
ProgIdClassId: K3dComConstants.KaEventHandlerClassId,
InprocServerPath: EventHandlerPath,
ThreadingModel: K3dComConstants.ApartmentThreadingModel,
TypeLibraryId: K3dComConstants.TypeLibraryId),
CurrentUserOverridePresent: false);
private static FakeBinaryInspector ReadyBinaries() => new FakeBinaryInspector()
.Set(TypeLibraryPath, exists: true, isAmd64: true)
.Set(EnginePath, exists: true, isAmd64: true)
.Set(EventHandlerPath, exists: true, isAmd64: true);
private static void AssertSafe(K3dRegistrationReport report)
{
Assert.DoesNotContain(TypeLibraryPath, report.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(EnginePath, report.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(EventHandlerPath, report.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("HKEY", report.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("CLSID\\", report.Message, StringComparison.OrdinalIgnoreCase);
}
private sealed class FakeSnapshotSource : IK3dRegistrySnapshotSource
{
private readonly K3dRegistrySnapshot _snapshot;
public FakeSnapshotSource(K3dRegistrySnapshot snapshot)
{
_snapshot = snapshot;
}
public K3dRegistrySnapshot Capture() => _snapshot;
}
private sealed class ThrowingSnapshotSource : IK3dRegistrySnapshotSource
{
public K3dRegistrySnapshot Capture() =>
throw new UnauthorizedAccessException("HKEY secret registry detail");
}
private sealed class FakeBinaryInspector : IPeBinaryInspector
{
private readonly Dictionary<string, PeBinaryInspection> _inspections =
new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _inspectedPaths = [];
public IReadOnlyList<string> InspectedPaths => _inspectedPaths;
public FakeBinaryInspector Set(string path, bool exists, bool isAmd64)
{
_inspections[path] = new PeBinaryInspection(exists, isAmd64);
return this;
}
public PeBinaryInspection Inspect(string? path)
{
if (path is null)
{
return new PeBinaryInspection(false, false);
}
_inspectedPaths.Add(path);
return _inspections.TryGetValue(path, out var inspection)
? inspection
: new PeBinaryInspection(false, false);
}
}
}

View File

@@ -0,0 +1,40 @@
using MBN_STOCK_WEBVIEW.Playout.Safety;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class LiveAuthorizationTests
{
[Theory]
[InlineData(null, false)]
[InlineData("I_AUTHORIZE_LIVE_PROGRAM_OUTPUT_FOR_THIS_LAUNCH ", false)]
[InlineData("i_authorize_live_program_output_for_this_launch", false)]
[InlineData("wrong", false)]
[InlineData(
PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
true)]
public void EnvironmentAuthorization_RequiresExactPerLaunchValue(
string? value,
bool expected)
{
using var environment = new EnvironmentScope()
.Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, value);
var authorization = new EnvironmentLiveAuthorization();
Assert.Equal(expected, authorization.IsAuthorizedForThisLaunch);
}
[Fact]
public void EnvironmentAuthorization_IsCapturedOnceAndCannotBeArmedAfterConstruction()
{
using var environment = new EnvironmentScope()
.Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, null);
var authorization = new EnvironmentLiveAuthorization();
environment.Set(
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
Assert.False(authorization.IsAuthorizedForThisLaunch);
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<Platforms>x64</Platforms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,195 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
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.Tests;
internal sealed class FakeK3dSessionFactory : IK3dSessionFactory
{
private readonly Func<FakeK3dSession> _create;
private int _createCount;
public FakeK3dSessionFactory(Func<FakeK3dSession>? create = null)
{
_create = create ?? (() => new FakeK3dSession());
}
public int CreateCount => Volatile.Read(ref _createCount);
public ConcurrentQueue<FakeK3dSession> Sessions { get; } = new();
public IK3dSession Create()
{
Interlocked.Increment(ref _createCount);
var session = _create();
Sessions.Enqueue(session);
return session;
}
}
internal sealed class FakeK3dSession : IK3dSession
{
private int _disposed;
public bool IsConnected { get; private set; }
public ConcurrentQueue<string> Calls { get; } = new();
public ConcurrentQueue<int> ThreadIds { get; } = new();
public ConcurrentQueue<ApartmentState> ApartmentStates { get; } = new();
public Action<ValidatedPlayoutOptions>? ConnectAction { get; set; }
public Action? DisconnectAction { get; set; }
public Action<PlayoutCue, int>? PrepareAction { get; set; }
public Action<int>? PlayAction { get; set; }
public Action<int, PlayoutTakeOutScope>? TakeOutAction { get; set; }
public void Connect(ValidatedPlayoutOptions options)
{
Record("Connect");
ConnectAction?.Invoke(options);
IsConnected = true;
}
public void Disconnect()
{
Record("Disconnect");
DisconnectAction?.Invoke();
IsConnected = false;
}
public void Prepare(PlayoutCue cue, int layoutIndex)
{
Record($"Prepare:{cue.SceneName}");
PrepareAction?.Invoke(cue, layoutIndex);
}
public void Play(int layoutIndex)
{
Record("Play");
PlayAction?.Invoke(layoutIndex);
}
public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope)
{
Record($"TakeOut:{scope}");
TakeOutAction?.Invoke(layoutIndex, scope);
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
Record("Dispose");
IsConnected = false;
}
}
private void Record(string name)
{
Calls.Enqueue(name);
ThreadIds.Enqueue(Environment.CurrentManagedThreadId);
ApartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState());
}
}
internal sealed class FakeRegistrationProbe : IK3dRegistrationProbe
{
private int _probeCount;
public FakeRegistrationProbe(K3dRegistrationReport? report = null)
{
Report = report ?? ReadyReport;
}
public static K3dRegistrationReport ReadyReport { get; } = new(
K3dRegistrationIssue.None,
Is64BitProcess: true,
HasWin64TypeLibrary: true,
IsWin64BinaryAmd64: true,
IsEngineClassReady: true,
IsEventHandlerClassReady: true,
Message: "fake K3D registration ready");
public K3dRegistrationReport Report { get; set; }
public int ProbeCount => Volatile.Read(ref _probeCount);
public K3dRegistrationReport Probe()
{
Interlocked.Increment(ref _probeCount);
return Report;
}
}
internal sealed class FakeTornadoProcessProbe : ITornadoProcessProbe
{
private readonly object _sync = new();
private readonly Queue<TornadoProcessSnapshot> _snapshots = new();
private TornadoProcessSnapshot _last;
public FakeTornadoProcessProbe(params TornadoProcessSnapshot[] snapshots)
{
_last = snapshots.LastOrDefault()
?? new TornadoProcessSnapshot(0, 0, 0);
foreach (var snapshot in snapshots)
{
_snapshots.Enqueue(snapshot);
}
}
public int CaptureCount { get; private set; }
public Regex? LastPattern { get; private set; }
public TornadoProcessSnapshot Capture(Regex? testWindowPattern)
{
lock (_sync)
{
CaptureCount++;
LastPattern = testWindowPattern;
if (_snapshots.Count > 0)
{
_last = _snapshots.Dequeue();
}
return _last;
}
}
}
internal sealed class FakeLiveAuthorization : ILiveAuthorization
{
public FakeLiveAuthorization(bool isAuthorizedForThisLaunch)
{
IsAuthorizedForThisLaunch = isAuthorizedForThisLaunch;
}
public bool IsAuthorizedForThisLaunch { get; }
}
internal sealed class TrackingStaDispatcherFactory : IStaDispatcherFactory
{
private int _createCount;
public int CreateCount => Volatile.Read(ref _createCount);
public ConcurrentQueue<IStaDispatcher> Dispatchers { get; } = new();
public IStaDispatcher Create(int capacity)
{
Interlocked.Increment(ref _createCount);
var dispatcher = new StaDispatcher(capacity);
Dispatchers.Enqueue(dispatcher);
return dispatcher;
}
}

View File

@@ -0,0 +1,162 @@
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PlayoutOptionsLoaderTests
{
private static readonly string[] EnvironmentNames =
[
"MBN_STOCK_PLAYOUT_MODE",
"MBN_STOCK_PLAYOUT_HOST",
"MBN_STOCK_PLAYOUT_PORT",
"MBN_STOCK_PLAYOUT_TCP_MODE",
"MBN_STOCK_PLAYOUT_CLIENT_PORT",
"MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL",
"MBN_STOCK_PLAYOUT_LAYOUT_INDEX",
"MBN_STOCK_PLAYOUT_SCENE_DIRECTORY",
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
"MBN_STOCK_PLAYOUT_QUEUE_CAPACITY",
"MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS",
"MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS",
"MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS",
"MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS",
"MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS",
"MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS",
"MBN_STOCK_PLAYOUT_RECONNECT_ENABLED",
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
"MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST",
"MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED"
];
[Fact]
public void Load_WhenFileIsMissing_UsesSafeDryRunDefaults()
{
using var environment = ClearedEnvironment();
using var file = TemporaryJsonFile.Missing();
var options = PlayoutOptionsLoader.Load(file.Path);
Assert.Equal(PlayoutMode.DryRun, options.Mode);
Assert.Equal("127.0.0.1", options.Host);
Assert.Equal(30001, options.Port);
Assert.Equal(1, options.TcpMode);
Assert.Equal(0, options.ClientPort);
Assert.Null(options.OutputChannel);
Assert.Null(options.SceneDirectory);
Assert.Empty(options.TestSceneAllowlist);
Assert.False(options.TrustedLiveOutputEnabled);
Assert.True(options.ReconnectEnabled);
}
[Fact]
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
{
using var environment = ClearedEnvironment()
.Set("MBN_STOCK_PLAYOUT_MODE", "DryRun")
.Set("MBN_STOCK_PLAYOUT_HOST", "env-host")
.Set("MBN_STOCK_PLAYOUT_PORT", "32001")
.Set("MBN_STOCK_PLAYOUT_TCP_MODE", "2")
.Set("MBN_STOCK_PLAYOUT_CLIENT_PORT", "32002")
.Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "12")
.Set("MBN_STOCK_PLAYOUT_LAYOUT_INDEX", "13")
.Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\env-scenes")
.Set("MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", "ENV TEST")
.Set("MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", "14")
.Set("MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS", "1501")
.Set("MBN_STOCK_PLAYOUT_OPERATION_TIMEOUT_MS", "1502")
.Set("MBN_STOCK_PLAYOUT_DISCONNECT_TIMEOUT_MS", "1503")
.Set("MBN_STOCK_PLAYOUT_PROCESS_POLL_INTERVAL_MS", "1504")
.Set("MBN_STOCK_PLAYOUT_RECONNECT_DELAY_MS", "1505")
.Set("MBN_STOCK_PLAYOUT_MAXIMUM_RECONNECT_ATTEMPTS", "4")
.Set("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "false")
.Set("MBN_STOCK_PLAYOUT_TEST_SCENE_ALLOWLIST", "C:\\env\\must-not-apply.t2s")
.Set("MBN_STOCK_PLAYOUT_TRUSTED_LIVE_OUTPUT_ENABLED", "false");
using var file = TemporaryJsonFile.Create(
"""
{
"mode": "Test",
"host": "json-host",
"port": 31001,
"tcpMode": 1,
"clientPort": 31002,
"outputChannel": 3,
"layoutIndex": 10,
"sceneDirectory": "C:\\json-scenes",
"testProcessWindowTitlePattern": "JSON TEST",
"testSceneAllowlist": ["C:\\test-scenes\\allowed.t2s"],
"trustedLiveOutputEnabled": true,
"queueCapacity": 64,
"connectTimeoutMilliseconds": 5000,
"operationTimeoutMilliseconds": 5000,
"disconnectTimeoutMilliseconds": 3000,
"processPollIntervalMilliseconds": 1000,
"reconnectDelayMilliseconds": 1000,
"maximumReconnectAttempts": 3,
"reconnectEnabled": true
}
""");
var options = PlayoutOptionsLoader.Load(file.Path);
Assert.Equal(PlayoutMode.DryRun, options.Mode);
Assert.Equal("env-host", options.Host);
Assert.Equal(32001, options.Port);
Assert.Equal(2, options.TcpMode);
Assert.Equal(32002, options.ClientPort);
Assert.Equal(12, options.OutputChannel);
Assert.Equal(13, options.LayoutIndex);
Assert.Equal("C:\\env-scenes", options.SceneDirectory);
Assert.Equal("ENV TEST", options.TestProcessWindowTitlePattern);
Assert.Equal(14, options.QueueCapacity);
Assert.Equal(1501, options.ConnectTimeoutMilliseconds);
Assert.Equal(1502, options.OperationTimeoutMilliseconds);
Assert.Equal(1503, options.DisconnectTimeoutMilliseconds);
Assert.Equal(1504, options.ProcessPollIntervalMilliseconds);
Assert.Equal(1505, options.ReconnectDelayMilliseconds);
Assert.Equal(4, options.MaximumReconnectAttempts);
Assert.False(options.ReconnectEnabled);
Assert.Equal(["C:\\test-scenes\\allowed.t2s"], options.TestSceneAllowlist);
Assert.True(options.TrustedLiveOutputEnabled);
}
[Fact]
public void Load_InvalidJson_ReturnsOnlySafeConfigurationMessage()
{
using var environment = ClearedEnvironment();
using var file = TemporaryJsonFile.Create("{ invalid-json: true }");
var exception = Assert.Throws<PlayoutConfigurationException>(
() => PlayoutOptionsLoader.Load(file.Path));
Assert.Equal("송출 설정 파일 형식이 올바르지 않습니다.", exception.Message);
Assert.DoesNotContain(file.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("invalid-json", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("MBN_STOCK_PLAYOUT_MODE", "not-a-mode")]
[InlineData("MBN_STOCK_PLAYOUT_PORT", "not-a-number")]
[InlineData("MBN_STOCK_PLAYOUT_RECONNECT_ENABLED", "not-a-bool")]
public void Load_InvalidEnvironmentValue_IdentifiesVariableWithoutLeakingValue(
string name,
string invalidValue)
{
using var environment = ClearedEnvironment().Set(name, invalidValue);
using var file = TemporaryJsonFile.Missing();
var exception = Assert.Throws<PlayoutConfigurationException>(
() => PlayoutOptionsLoader.Load(file.Path));
Assert.Contains(name, exception.Message, StringComparison.Ordinal);
Assert.DoesNotContain(invalidValue, exception.Message, StringComparison.Ordinal);
}
private static EnvironmentScope ClearedEnvironment()
{
var result = new EnvironmentScope();
foreach (var name in EnvironmentNames)
{
result.Set(name, null);
}
return result;
}
}

View File

@@ -0,0 +1,229 @@
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PlayoutSafetyValidationTests : IDisposable
{
private readonly TemporarySceneDirectory _scenes =
TemporarySceneDirectory.Create("test-scene.t2s");
[Theory]
[InlineData("Tornado2", true)]
[InlineData("tornado2-test", true)]
[InlineData("TORNADO2Preview", true)]
[InlineData("Tornado20", true)]
[InlineData("Tornado", false)]
[InlineData("PreviewTornado2", false)]
[InlineData(null, false)]
public void IsTornadoProcessName_UsesCaseInsensitiveTornado2Prefix(
string? processName,
bool expected)
{
Assert.Equal(expected, TornadoProcessProbe.IsTornadoProcessName(processName));
}
[Theory]
[InlineData("Tornado2 TEST", false)]
[InlineData("Tornado2 PGM", true)]
[InlineData("PROGRAM OUTPUT", true)]
[InlineData("preview pgm backup", true)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsProgramTitle_RejectsPgmAndProgramWindows(string? title, bool expected)
{
Assert.Equal(expected, TornadoProcessProbe.IsProgramTitle(title));
}
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
[InlineData(2, false)]
[InlineData(10, false)]
public void ProcessSnapshot_RequiresExactlyOneEligibleInstance(
int eligibleProcessCount,
bool expected)
{
var snapshot = new TornadoProcessSnapshot(
TotalProcessCount: eligibleProcessCount,
EligibleProcessCount: eligibleProcessCount,
ProgramProcessCount: 0);
Assert.Equal(expected, snapshot.HasSingleEligibleProcess);
}
[Fact]
public void Validate_TestModeRequiresExplicitOutputChannel()
{
var options = ValidTestOptions();
options.OutputChannel = null;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(nameof(PlayoutOptions.OutputChannel), exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_TestModeRequiresSceneAllowlist()
{
var options = ValidTestOptions();
options.TestSceneAllowlist = [];
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(nameof(PlayoutOptions.TestSceneAllowlist), exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("localhost")]
[InlineData("test-tornado.local")]
[InlineData("192.168.0.10")]
public void Validate_TestModeRequiresLiteralLoopbackAddress(string host)
{
var options = ValidTestOptions();
options.Host = host;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(nameof(PlayoutOptions.Host), exception.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData(".*")]
[InlineData("PGM")]
[InlineData("PROGRAM")]
[InlineData("^Tornado2 (TEST|PGM)$")]
public void Validate_TestWindowPatternThatCanMatchProgramOutput_IsRejected(string pattern)
{
var options = ValidTestOptions();
options.TestProcessWindowTitlePattern = pattern;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains("PROGRAM", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_InvalidTestWindowPattern_IsRejectedWithoutEchoingPattern()
{
const string invalidPattern = "(?<secret>";
var options = ValidTestOptions();
options.TestProcessWindowTitlePattern = invalidPattern;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Equal("테스트 Tornado 창 제목 설정이 올바르지 않습니다.", exception.Message);
Assert.DoesNotContain(invalidPattern, exception.Message, StringComparison.Ordinal);
}
[Fact]
public void Validate_TestSceneAllowlistMatchesSceneNameOnlyAndIgnoresCase()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
Assert.True(validated.IsTestSceneAllowed(
new PlayoutCue("test-scene.t2s", "TEST-SCENE")));
Assert.False(validated.IsTestSceneAllowed(
new PlayoutCue("test-scene.t2s", "OTHER-SCENE")));
}
[Fact]
public void ResolveCue_UsesCanonicalFileUnderConfiguredSceneDirectory()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"TEST-SCENE",
FadeDuration: 5));
Assert.Equal(
System.IO.Path.Combine(_scenes.Path, "test-scene.t2s"),
resolved.SceneFile,
ignoreCase: true);
Assert.Equal("TEST-SCENE", resolved.SceneName);
Assert.Equal(5, resolved.FadeDuration);
}
[Theory]
[InlineData("..\\outside.t2s", "outside")]
[InlineData("test-scene.txt", "test-scene")]
[InlineData("test-scene.t2s", "different-name")]
[InlineData("missing.t2s", "missing")]
public void ResolveCue_RejectsUnsafeOrMissingFilesWithoutLeakingInput(
string sceneFile,
string sceneName)
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var exception = Assert.Throws<PlayoutRequestException>(
() => validated.ResolveCue(new PlayoutCue(sceneFile, sceneName)));
Assert.DoesNotContain(sceneFile, exception.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ResolveCue_RejectsRootedScenePathWithoutLeakingIt()
{
var validated = ValidatedPlayoutOptions.Create(ValidTestOptions());
var rootedPath = System.IO.Path.Combine(_scenes.Path, "test-scene.t2s");
var exception = Assert.Throws<PlayoutRequestException>(
() => validated.ResolveCue(new PlayoutCue(rootedPath, "test-scene")));
Assert.Equal("장면 파일 이름이 올바르지 않습니다.", exception.Message);
Assert.DoesNotContain(rootedPath, exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(0, 5000, 64, nameof(PlayoutOptions.Port))]
[InlineData(30001, 99, 64, nameof(PlayoutOptions.OperationTimeoutMilliseconds))]
[InlineData(30001, 5000, 0, nameof(PlayoutOptions.QueueCapacity))]
public void Validate_OutOfRangeValuesAreRejected(
int port,
int operationTimeoutMilliseconds,
int queueCapacity,
string expectedProperty)
{
var options = ValidTestOptions();
options.Port = port;
options.OperationTimeoutMilliseconds = operationTimeoutMilliseconds;
options.QueueCapacity = queueCapacity;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(expectedProperty, exception.Message, StringComparison.Ordinal);
}
public void Dispose() => _scenes.Dispose();
private PlayoutOptions ValidTestOptions() => new()
{
Mode = PlayoutMode.Test,
Host = "127.0.0.1",
Port = 30001,
TcpMode = 1,
ClientPort = 0,
OutputChannel = 9,
LayoutIndex = 10,
SceneDirectory = _scenes.Path,
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
TestSceneAllowlist = ["test-scene"],
TrustedLiveOutputEnabled = false,
QueueCapacity = 64,
ConnectTimeoutMilliseconds = 5000,
OperationTimeoutMilliseconds = 5000,
DisconnectTimeoutMilliseconds = 3000,
ProcessPollIntervalMilliseconds = 1000,
ReconnectDelayMilliseconds = 1000,
MaximumReconnectAttempts = 3,
ReconnectEnabled = true
};
}

View File

@@ -0,0 +1,169 @@
using System.Collections.Concurrent;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class StaDispatcherTests
{
[Fact]
public async Task InvokeAsync_RunsCallbacksOnOneStaThreadInFifoOrder()
{
await using var dispatcher = new StaDispatcher(capacity: 4);
using var firstStarted = new ManualResetEventSlim();
using var releaseFirst = new ManualResetEventSlim();
var calls = new ConcurrentQueue<(int Order, int ThreadId, ApartmentState Apartment)>();
var first = dispatcher.InvokeAsync(
() =>
{
calls.Enqueue((
1,
Environment.CurrentManagedThreadId,
Thread.CurrentThread.GetApartmentState()));
firstStarted.Set();
Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5)));
return 1;
},
TimeSpan.FromSeconds(10),
CancellationToken.None);
Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(5)));
var second = dispatcher.InvokeAsync(
() =>
{
calls.Enqueue((
2,
Environment.CurrentManagedThreadId,
Thread.CurrentThread.GetApartmentState()));
return 2;
},
TimeSpan.FromSeconds(10),
CancellationToken.None);
var third = dispatcher.InvokeAsync(
() =>
{
calls.Enqueue((
3,
Environment.CurrentManagedThreadId,
Thread.CurrentThread.GetApartmentState()));
return 3;
},
TimeSpan.FromSeconds(10),
CancellationToken.None);
releaseFirst.Set();
var results = await Task.WhenAll(first, second, third);
Assert.Equal(new[] { 1, 2, 3 }, results);
var recorded = calls.ToArray();
Assert.Equal([1, 2, 3], recorded.Select(call => call.Order));
Assert.Single(recorded.Select(call => call.ThreadId).Distinct());
Assert.All(recorded, call => Assert.Equal(ApartmentState.STA, call.Apartment));
Assert.Equal(recorded[0].ThreadId, dispatcher.ManagedThreadId);
}
[Fact]
public async Task InvokeAsync_CancelledWhileQueued_DoesNotRunCallback()
{
await using var dispatcher = new StaDispatcher(capacity: 2);
using var firstStarted = new ManualResetEventSlim();
using var releaseFirst = new ManualResetEventSlim();
var cancelledCallbackRan = false;
var first = dispatcher.InvokeAsync(
() =>
{
firstStarted.Set();
Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5)));
return 1;
},
TimeSpan.FromSeconds(10),
CancellationToken.None);
Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(5)));
using var cancellation = new CancellationTokenSource();
var cancelled = dispatcher.InvokeAsync(
() =>
{
cancelledCallbackRan = true;
return 2;
},
TimeSpan.FromSeconds(10),
cancellation.Token);
cancellation.Cancel();
releaseFirst.Set();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => cancelled);
Assert.Equal(1, await first);
Assert.False(cancelledCallbackRan);
}
[Fact]
public async Task InvokeAsync_WhenBoundedQueueIsFull_CanCancelWithoutRunning()
{
await using var dispatcher = new StaDispatcher(capacity: 1);
using var firstStarted = new ManualResetEventSlim();
using var releaseFirst = new ManualResetEventSlim();
var thirdCallbackRan = false;
var first = dispatcher.InvokeAsync(
() =>
{
firstStarted.Set();
Assert.True(releaseFirst.Wait(TimeSpan.FromSeconds(5)));
return 1;
},
TimeSpan.FromSeconds(10),
CancellationToken.None);
Assert.True(firstStarted.Wait(TimeSpan.FromSeconds(5)));
var second = dispatcher.InvokeAsync(
() => 2,
TimeSpan.FromSeconds(10),
CancellationToken.None);
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var third = dispatcher.InvokeAsync(
() =>
{
thirdCallbackRan = true;
return 3;
},
TimeSpan.FromSeconds(10),
cancellation.Token);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => third);
Assert.False(thirdCallbackRan);
releaseFirst.Set();
var results = await Task.WhenAll(first, second);
Assert.Equal(new[] { 1, 2 }, results);
}
[Fact]
public async Task InvokeAsync_TimeoutQuarantinesDispatcherAndRejectsLaterWork()
{
await using var dispatcher = new StaDispatcher(capacity: 2);
using var callbackStarted = new ManualResetEventSlim();
using var releaseCallback = new ManualResetEventSlim();
var timedOut = dispatcher.InvokeAsync(
() =>
{
callbackStarted.Set();
Assert.True(releaseCallback.Wait(TimeSpan.FromSeconds(5)));
return 1;
},
TimeSpan.FromMilliseconds(100),
CancellationToken.None);
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(5)));
await Assert.ThrowsAsync<StaOperationTimedOutException>(() => timedOut);
Assert.True(dispatcher.IsQuarantined);
await Assert.ThrowsAsync<StaDispatcherQuarantinedException>(
() => dispatcher.InvokeAsync(
() => 2,
TimeSpan.FromSeconds(1),
CancellationToken.None));
releaseCallback.Set();
}
}

View File

@@ -0,0 +1,118 @@
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
internal sealed class EnvironmentScope : IDisposable
{
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.Ordinal);
public EnvironmentScope Set(string name, string? value)
{
if (!_originalValues.ContainsKey(name))
{
_originalValues.Add(name, Environment.GetEnvironmentVariable(name));
}
Environment.SetEnvironmentVariable(name, value);
return this;
}
public void Dispose()
{
foreach (var pair in _originalValues)
{
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
}
}
}
internal sealed class TemporaryJsonFile : IDisposable
{
private TemporaryJsonFile(string path)
{
Path = path;
}
public string Path { get; }
public static TemporaryJsonFile Create(string json)
{
var directory = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"MBN_STOCK_WEBVIEW.Playout.Tests",
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var path = System.IO.Path.Combine(directory, "playout.local.json");
File.WriteAllText(path, json);
return new TemporaryJsonFile(path);
}
public static TemporaryJsonFile Missing()
{
var directory = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"MBN_STOCK_WEBVIEW.Playout.Tests",
Guid.NewGuid().ToString("N"));
return new TemporaryJsonFile(
System.IO.Path.Combine(directory, "missing-playout.local.json"));
}
public void Dispose()
{
var directory = System.IO.Path.GetDirectoryName(Path);
if (directory is not null && Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
}
internal sealed class TemporarySceneDirectory : IDisposable
{
private TemporarySceneDirectory(string path)
{
Path = path;
}
public string Path { get; }
public static TemporarySceneDirectory Create(params string[] relativeFiles)
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"MBN_STOCK_WEBVIEW.Playout.Tests",
Guid.NewGuid().ToString("N"),
"Scenes");
Directory.CreateDirectory(path);
var result = new TemporarySceneDirectory(path);
foreach (var relativeFile in relativeFiles)
{
result.AddFile(relativeFile);
}
return result;
}
public string AddFile(string relativePath)
{
var fullPath = System.IO.Path.GetFullPath(System.IO.Path.Combine(Path, relativePath));
var rootPrefix = Path.EndsWith(System.IO.Path.DirectorySeparatorChar)
? Path
: Path + System.IO.Path.DirectorySeparatorChar;
if (!fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("Test file must remain under the temporary scene directory.", nameof(relativePath));
}
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, "fake test scene; never loaded by a real COM server");
return fullPath;
}
public void Dispose()
{
var parent = Directory.GetParent(Path)?.FullName;
if (parent is not null && Directory.Exists(parent))
{
Directory.Delete(parent, recursive: true);
}
}
}

View File

@@ -0,0 +1,435 @@
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class TornadoPlayoutEngineTests
{
[Theory]
[InlineData(0, false)]
[InlineData(1, true)]
public async Task DefaultDryRun_MonitorsProcessWithoutCreatingComSession(
int totalProcessCount,
bool expectedRunning)
{
var sessionFactory = new FakeK3dSessionFactory();
var registration = new FakeRegistrationProbe();
var process = new FakeTornadoProcessProbe(
new TornadoProcessSnapshot(totalProcessCount, totalProcessCount, 0));
await using var engine = CreateEngine(
new PlayoutOptions(),
sessionFactory,
registration,
process,
liveAuthorized: false);
await engine.PollProcessOnceAsync(CancellationToken.None);
var connect = await engine.ConnectAsync(CancellationToken.None);
var prepare = await engine.PrepareAsync(
new PlayoutCue("never-resolved.t2s", "dry-run-scene"),
CancellationToken.None);
Assert.Equal(PlayoutMode.DryRun, engine.Status.Mode);
Assert.Equal(expectedRunning, engine.Status.IsProcessRunning);
Assert.Equal(PlayoutResultCode.Success, connect.Code);
Assert.Equal(PlayoutResultCode.Success, prepare.Code);
Assert.Equal(0, sessionFactory.CreateCount);
Assert.Equal(0, registration.ProbeCount);
}
[Fact]
public async Task CancelledCommandBeforeSerialization_DoesNotProbeOrCreateSession()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory();
var registration = new FakeRegistrationProbe();
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
registration,
process,
liveAuthorized: false);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var result = await engine.ConnectAsync(cancellation.Token);
Assert.Equal(PlayoutResultCode.Cancelled, result.Code);
Assert.Equal(0, sessionFactory.CreateCount);
Assert.Equal(0, registration.ProbeCount);
Assert.Equal(0, process.CaptureCount);
}
[Theory]
[InlineData(0, 0, 0, false, "찾을 수 없습니다")]
[InlineData(2, 1, 0, false, "여러 개")]
[InlineData(1, 0, 1, false, "PROGRAM")]
[InlineData(1, 1, 0, true, null)]
public async Task TestConnect_RequiresExactlyOneEligibleNonProgramInstance(
int totalProcessCount,
int eligibleProcessCount,
int programProcessCount,
bool expectedSuccess,
string? expectedFailureText)
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory();
var registration = new FakeRegistrationProbe();
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(
totalProcessCount,
eligibleProcessCount,
programProcessCount));
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
registration,
process,
liveAuthorized: false);
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(expectedSuccess, result.IsSuccess);
Assert.Equal(expectedSuccess ? 1 : 0, sessionFactory.CreateCount);
Assert.Equal(expectedSuccess ? 1 : 0, registration.ProbeCount);
Assert.NotNull(process.LastPattern);
if (expectedSuccess)
{
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
Assert.True(engine.Status.LiveTakeInAllowed);
}
else
{
Assert.Equal(PlayoutResultCode.Unavailable, result.Code);
Assert.Contains(expectedFailureText!, result.Message, StringComparison.OrdinalIgnoreCase);
Assert.False(engine.Status.LiveTakeInAllowed);
}
}
[Fact]
public async Task TestWorkflow_ExecutesFakeSessionInCommandOrderOnSta()
{
using var scenes = TemporarySceneDirectory.Create("first.t2s", "next.t2s");
var session = new FakeK3dSession();
var sessionFactory = new FakeK3dSessionFactory(() => session);
var process = new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0));
await using var engine = CreateEngine(
TestOptions(scenes.Path, "first", "next"),
sessionFactory,
new FakeRegistrationProbe(),
process,
liveAuthorized: false);
var connect = await engine.ConnectAsync(CancellationToken.None);
Assert.True(engine.Status.LiveTakeInAllowed);
var prepare = await engine.PrepareAsync(Cue("first"), CancellationToken.None);
var takeIn = await engine.TakeInAsync(CancellationToken.None);
var next = await engine.NextAsync(Cue("next"), CancellationToken.None);
var takeOut = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None);
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
Assert.All(
new[] { connect, prepare, takeIn, next, takeOut, disconnect },
result => Assert.Equal(PlayoutResultCode.Success, result.Code));
Assert.Equal(
new[]
{
"Connect",
"Prepare:first",
"Play",
"Prepare:next",
"Play",
"TakeOut:All",
"Disconnect",
"Dispose"
},
session.Calls);
Assert.Single(session.ThreadIds.Distinct());
Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
Assert.Equal(PlayoutConnectionState.Disconnected, engine.Status.State);
Assert.False(engine.Status.LiveTakeInAllowed);
Assert.Null(engine.Status.PreparedSceneName);
Assert.Null(engine.Status.OnAirSceneName);
}
[Fact]
public async Task TestSceneOutsideAllowlist_IsRejectedBeforeSessionCall()
{
using var scenes = TemporarySceneDirectory.Create("allowed.t2s", "blocked.t2s");
var session = new FakeK3dSession();
var sessionFactory = new FakeK3dSessionFactory(() => session);
await using var engine = CreateEngine(
TestOptions(scenes.Path, "allowed"),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var prepare = await engine.PrepareAsync(Cue("blocked"), CancellationToken.None);
var next = await engine.NextAsync(Cue("blocked"), CancellationToken.None);
Assert.Equal(PlayoutResultCode.Rejected, prepare.Code);
Assert.Equal(PlayoutResultCode.Rejected, next.Code);
Assert.DoesNotContain(session.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal));
Assert.DoesNotContain("blocked.t2s", prepare.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(scenes.Path, prepare.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
public async Task LiveWithoutBothAuthorizations_RejectsCommandsBeforeCom(
bool trustedLiveOutputEnabled,
bool launchAuthorized)
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory();
var registration = new FakeRegistrationProbe();
await using var engine = CreateEngine(
LiveOptions(scenes.Path, trustedLiveOutputEnabled),
sessionFactory,
registration,
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
launchAuthorized);
var results = new[]
{
await engine.ConnectAsync(CancellationToken.None),
await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None),
await engine.TakeInAsync(CancellationToken.None),
await engine.NextAsync(Cue("live-scene"), CancellationToken.None),
await engine.TakeOutAsync(PlayoutTakeOutScope.All, CancellationToken.None)
};
Assert.All(results, result => Assert.Equal(PlayoutResultCode.Rejected, result.Code));
Assert.Equal(0, sessionFactory.CreateCount);
Assert.Equal(0, registration.ProbeCount);
Assert.False(engine.Status.LiveTakeInAllowed);
}
[Fact]
public async Task LiveWithBothAuthorizations_CanUseFakeSessionOnly()
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var session = new FakeK3dSession();
await using var engine = CreateEngine(
LiveOptions(scenes.Path, trustedLiveOutputEnabled: true),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: true);
var connect = await engine.ConnectAsync(CancellationToken.None);
var prepare = await engine.PrepareAsync(Cue("live-scene"), CancellationToken.None);
var takeIn = await engine.TakeInAsync(CancellationToken.None);
Assert.True(connect.IsSuccess);
Assert.True(prepare.IsSuccess);
Assert.True(takeIn.IsSuccess);
Assert.True(engine.Status.LiveTakeInAllowed);
Assert.Contains("Play", session.Calls);
}
[Fact]
public async Task TimedOutTakeIn_QuarantinesEngineAndNeverReconnectsOrReplays()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var playStarted = new ManualResetEventSlim();
using var releasePlay = new ManualResetEventSlim();
var session = new FakeK3dSession
{
PlayAction = _ =>
{
playStarted.Set();
Assert.True(releasePlay.Wait(TimeSpan.FromSeconds(5)));
}
};
var sessionFactory = new FakeK3dSessionFactory(() => session);
var options = TestOptions(scenes.Path, "test-scene");
options.OperationTimeoutMilliseconds = 100;
options.ReconnectDelayMilliseconds = 0;
await using var engine = CreateEngine(
options,
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess);
try
{
var takeInTask = engine.TakeInAsync(CancellationToken.None);
Assert.True(playStarted.Wait(TimeSpan.FromSeconds(5)));
var takeIn = await takeInTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.False(engine.Status.IsCommandAvailable);
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
releasePlay.Set();
await engine.PollProcessOnceAsync(CancellationToken.None);
var takeOut = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code);
Assert.Equal(1, sessionFactory.CreateCount);
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
Assert.DoesNotContain(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal));
}
finally
{
releasePlay.Set();
}
}
[Fact]
public async Task OutcomeUnknown_PreservesLatchAndNeverReconnectsOrReplays()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var secretPath = System.IO.Path.Combine(scenes.Path, "test-scene.t2s");
var first = new FakeK3dSession
{
PlayAction = _ => throw new COMException(
$"vendor HRESULT 0x80004005 at {secretPath}",
unchecked((int)0x80004005))
};
var second = new FakeK3dSession();
var created = 0;
var sessionFactory = new FakeK3dSessionFactory(
() => Interlocked.Increment(ref created) == 1 ? first : second);
var options = TestOptions(scenes.Path, "test-scene");
options.ReconnectDelayMilliseconds = 0;
await using var engine = CreateEngine(
options,
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None)).IsSuccess);
var takeIn = await engine.TakeInAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code);
Assert.DoesNotContain("0x80004005", takeIn.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(secretPath, takeIn.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("0x80004005", engine.Status.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(secretPath, engine.Status.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(1, first.Calls.Count(call => call == "Play"));
await engine.PollProcessOnceAsync(CancellationToken.None);
var laterCommand = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None);
Assert.Equal(1, sessionFactory.CreateCount);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterCommand.Code);
Assert.False(engine.Status.IsCommandAvailable);
Assert.DoesNotContain("Play", second.Calls);
Assert.DoesNotContain(second.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal));
}
[Fact]
public async Task FailedPrepare_ReconnectsWithoutReplayingTheFailedCommand()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var first = new FakeK3dSession
{
PrepareAction = (_, _) => throw new InvalidOperationException("fake prepare failure")
};
var second = new FakeK3dSession();
var created = 0;
var sessionFactory = new FakeK3dSessionFactory(
() => Interlocked.Increment(ref created) == 1 ? first : second);
var options = TestOptions(scenes.Path, "test-scene");
options.ReconnectDelayMilliseconds = 0;
await using var engine = CreateEngine(
options,
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var prepare = await engine.PrepareAsync(Cue("test-scene"), CancellationToken.None);
Assert.Equal(PlayoutResultCode.Failed, prepare.Code);
Assert.Equal(1, first.Calls.Count(call => call == "Prepare:test-scene"));
Assert.Equal(PlayoutConnectionState.Reconnecting, engine.Status.State);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(2, sessionFactory.CreateCount);
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
Assert.DoesNotContain(second.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal));
Assert.DoesNotContain("Play", second.Calls);
}
private static TornadoPlayoutEngine CreateEngine(
PlayoutOptions rawOptions,
FakeK3dSessionFactory sessionFactory,
FakeRegistrationProbe registrationProbe,
FakeTornadoProcessProbe processProbe,
bool liveAuthorized)
{
var options = ValidatedPlayoutOptions.Create(rawOptions);
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
? new StaDispatcher(options.QueueCapacity)
: null;
return new TornadoPlayoutEngine(
options,
sessionFactory,
registrationProbe,
processProbe,
dispatcher,
new FakeLiveAuthorization(liveAuthorized),
TimeProvider.System,
startMonitor: false);
}
private static PlayoutOptions TestOptions(
string sceneDirectory,
params string[] allowedScenes) => new()
{
Mode = PlayoutMode.Test,
SceneDirectory = sceneDirectory,
OutputChannel = 9,
TestProcessWindowTitlePattern = "^Tornado2 TEST$",
TestSceneAllowlist = [.. allowedScenes],
ConnectTimeoutMilliseconds = 500,
OperationTimeoutMilliseconds = 500,
DisconnectTimeoutMilliseconds = 500,
ProcessPollIntervalMilliseconds = 100,
ReconnectDelayMilliseconds = 0,
MaximumReconnectAttempts = 3
};
private static PlayoutOptions LiveOptions(
string sceneDirectory,
bool trustedLiveOutputEnabled) => new()
{
Mode = PlayoutMode.Live,
SceneDirectory = sceneDirectory,
TrustedLiveOutputEnabled = trustedLiveOutputEnabled,
ConnectTimeoutMilliseconds = 500,
OperationTimeoutMilliseconds = 500,
DisconnectTimeoutMilliseconds = 500,
ProcessPollIntervalMilliseconds = 100,
ReconnectDelayMilliseconds = 0,
MaximumReconnectAttempts = 3
};
private static PlayoutCue Cue(string sceneName) => new(
$"{sceneName}.t2s",
sceneName,
[new PlayoutField("headline", "fake", true)]);
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<Platforms>x64</Platforms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Playout\MBN_STOCK_WEBVIEW.Playout.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,124 @@
using System.Diagnostics;
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Registration;
var command = args.Length == 0 ? "--probe" : args[0].ToLowerInvariant();
return command switch
{
"--probe" => Probe(),
"--dry-run" => await DryRunAsync(),
_ => Usage()
};
static int Probe()
{
var runtimeRegistration = new K3dRegistrationProbe().Probe();
var processInspectionAvailable = true;
var tornadoProcessCount = 0;
var programWindowDetected = false;
Process[] processes;
try
{
processes = Process.GetProcesses();
}
catch
{
processes = [];
processInspectionAvailable = false;
}
foreach (var process in processes)
{
try
{
if (!process.ProcessName.StartsWith("Tornado2", StringComparison.OrdinalIgnoreCase))
{
continue;
}
tornadoProcessCount++;
var title = process.MainWindowTitle;
if (title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase))
{
programWindowDetected = true;
}
}
catch
{
// A process may exit or deny inspection between enumeration and reading.
}
finally
{
process.Dispose();
}
}
Console.WriteLine(JsonSerializer.Serialize(new
{
architecture = Environment.Is64BitProcess ? "x64" : "x86",
runtimeRegistration = new
{
ready = runtimeRegistration.IsReady,
issues = runtimeRegistration.Issues.ToString(),
engineClassReady = runtimeRegistration.IsEngineClassReady,
eventHandlerClassReady = runtimeRegistration.IsEventHandlerClassReady,
message = runtimeRegistration.Message
},
tornado = new
{
inspectionAvailable = processInspectionAvailable,
processCount = tornadoProcessCount,
programWindowDetected
},
safety = programWindowDetected
? "PROGRAM window detected; no COM activation was attempted."
: "Read-only probe complete; no COM activation was attempted."
}, new JsonSerializerOptions { WriteIndented = true }));
return runtimeRegistration.IsReady && Environment.Is64BitProcess
? 0
: 2;
}
static async Task<int> DryRunAsync()
{
var options = new PlayoutOptions { Mode = PlayoutMode.DryRun };
await using var engine = PlayoutEngineFactory.Create(options);
var cue = new PlayoutCue("DRY_RUN_ONLY.t2s", "DRY_RUN_ONLY");
var results = new[]
{
await engine.ConnectAsync(),
await engine.PrepareAsync(cue),
await engine.TakeInAsync(),
await engine.NextAsync(cue with { SceneName = "DRY_RUN_NEXT" }),
await engine.TakeOutAsync(PlayoutTakeOutScope.All),
await engine.DisconnectAsync()
};
Console.WriteLine(JsonSerializer.Serialize(new
{
mode = engine.Status.Mode.ToString(),
state = engine.Status.State.ToString(),
commands = results.Select(result => new
{
operation = result.Operation.ToString(),
code = result.Code.ToString(),
message = result.Message
})
}, new JsonSerializerOptions { WriteIndented = true }));
return results.All(result => result.IsSuccess) ? 0 : 3;
}
static int Usage()
{
Console.Error.WriteLine("Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run]");
Console.Error.WriteLine("This tool never enables Test or Live mode and never activates K3D COM in dry-run.");
return 64;
}