feat: migrate legacy playout workflow and scenes

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

View File

@@ -1,4 +1,5 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
@@ -20,6 +21,22 @@ public sealed class PlayoutOptions
public int LayoutIndex { get; set; } = 10;
/// <summary>MainForm ComboDi.SelectedIndex default passed to SetSceneEffectType.</summary>
public int LegacySceneFadeDuration { get; set; } = 6;
/// <summary>
/// Trusted MainForm-level background selection. The asset is always a path relative
/// to SceneDirectory and is never accepted from Web content.
/// </summary>
public LegacySceneBackgroundKind LegacySceneBackgroundKind { get; set; } =
LegacySceneBackgroundKind.None;
public string? LegacySceneBackgroundAssetPath { get; set; }
public int LegacySceneBackgroundVideoLoopCount { get; set; } = 2004;
public bool LegacySceneBackgroundVideoLoopInfinite { get; set; } = true;
/// <summary>
/// External absolute root containing vendor .t2s scenes. Required in Test and Live modes.
/// </summary>
@@ -27,6 +44,10 @@ public sealed class PlayoutOptions
public string? TestProcessWindowTitlePattern { get; set; }
/// <summary>
/// Closed scene-name allowlist for every real Test or Live output command.
/// The property name is retained for existing local configuration files.
/// </summary>
public List<string> TestSceneAllowlist { get; set; } = [];
public bool TrustedLiveOutputEnabled { get; set; }

View File

@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
@@ -73,13 +74,30 @@ public static class PlayoutOptionsLoader
private static void ApplyEnvironment(PlayoutOptions options)
{
ApplyEnum("MBN_STOCK_PLAYOUT_MODE", value => options.Mode = value);
ApplyEnum(
"MBN_STOCK_PLAYOUT_MODE",
(PlayoutMode 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);
ApplyInt(
"MBN_STOCK_PLAYOUT_LEGACY_FADE_DURATION",
value => options.LegacySceneFadeDuration = value);
ApplyEnum(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND",
(LegacySceneBackgroundKind value) => options.LegacySceneBackgroundKind = value);
ApplyString(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET",
value => options.LegacySceneBackgroundAssetPath = value);
ApplyInt(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT",
value => options.LegacySceneBackgroundVideoLoopCount = value);
ApplyBool(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE",
value => options.LegacySceneBackgroundVideoLoopInfinite = value);
ApplyString("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", value => options.SceneDirectory = value);
ApplyString(
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
@@ -177,7 +195,8 @@ public static class PlayoutOptionsLoader
apply(value);
}
private static void ApplyEnum(string name, Action<PlayoutMode> apply)
private static void ApplyEnum<TEnum>(string name, Action<TEnum> apply)
where TEnum : struct, Enum
{
var text = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(text))
@@ -185,7 +204,7 @@ public static class PlayoutOptionsLoader
return;
}
if (!Enum.TryParse<PlayoutMode>(text, true, out var value))
if (!Enum.TryParse<TEnum>(text, true, out var value) || !Enum.IsDefined(value))
{
throw InvalidEnvironment(name);
}

View File

@@ -0,0 +1,109 @@
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
/// <summary>
/// Validates the trusted MainForm-level fade/background configuration before the
/// first DryRun or actual PREPARE. Web content never supplies these values.
/// </summary>
public static class PlayoutSceneCompositionFactory
{
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
[
".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds",
".vrv", ".avi", ".mp4", ".mov"
], StringComparer.OrdinalIgnoreCase);
public static LegacySceneCueCompositionOptions Create(PlayoutOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.LegacySceneFadeDuration is < 0 or > 60 ||
!Enum.IsDefined(options.LegacySceneBackgroundKind) ||
options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000)
{
throw Invalid();
}
if (options.LegacySceneBackgroundKind == LegacySceneBackgroundKind.None)
{
if (!string.IsNullOrWhiteSpace(options.LegacySceneBackgroundAssetPath))
{
throw Invalid();
}
return new LegacySceneCueCompositionOptions(
options.LegacySceneFadeDuration,
LegacySceneBackgroundKind.None);
}
var rootText = options.SceneDirectory?.Trim();
var relativeText = options.LegacySceneBackgroundAssetPath?.Trim();
if (string.IsNullOrEmpty(rootText) || !Path.IsPathFullyQualified(rootText) ||
string.IsNullOrEmpty(relativeText) || Path.IsPathRooted(relativeText) ||
relativeText.Contains("..", StringComparison.Ordinal) ||
relativeText.Contains(':') || relativeText.Any(char.IsControl) ||
!AllowedExtensions.Contains(Path.GetExtension(relativeText)))
{
throw Invalid();
}
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootText));
if (!Directory.Exists(root) || IsReparsePoint(root))
{
throw Invalid();
}
var candidate = Path.GetFullPath(Path.Combine(root, relativeText));
var rootPrefix = root + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) || HasReparsePointBetween(root, candidate))
{
throw Invalid();
}
var normalizedRelative = Path.GetRelativePath(root, candidate);
return new LegacySceneCueCompositionOptions(
options.LegacySceneFadeDuration,
options.LegacySceneBackgroundKind,
normalizedRelative,
options.LegacySceneBackgroundVideoLoopCount,
options.LegacySceneBackgroundVideoLoopInfinite);
}
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 false;
}
private static bool IsReparsePoint(string path)
{
try
{
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
}
catch
{
return true;
}
}
private static PlayoutConfigurationException Invalid() => new(
"공통 장면 배경 설정이 올바르지 않거나 허용된 scene 폴더의 자산을 찾을 수 없습니다.");
}

View File

@@ -104,6 +104,11 @@ internal sealed record ValidatedPlayoutOptions(
allowlist.Add(value!);
}
if (options.Mode is PlayoutMode.Test or PlayoutMode.Live && allowlist.Count == 0)
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
if (options.Mode == PlayoutMode.Test)
{
if (!IsLiteralLoopback(host))
@@ -116,11 +121,6 @@ internal sealed record ValidatedPlayoutOptions(
throw Invalid(nameof(options.OutputChannel));
}
if (allowlist.Count == 0)
{
throw Invalid(nameof(options.TestSceneAllowlist));
}
var pattern = RequiredText(
options.TestProcessWindowTitlePattern,
nameof(options.TestProcessWindowTitlePattern));
@@ -170,6 +170,13 @@ internal sealed record ValidatedPlayoutOptions(
}
public bool IsTestSceneAllowed(PlayoutCue cue)
=> IsOutputSceneAllowed(cue);
/// <summary>
/// Closed scene allowlist applied to every real Test or Live mutation command.
/// The legacy property name is retained for local-config compatibility.
/// </summary>
public bool IsOutputSceneAllowed(PlayoutCue cue)
{
var sceneName = cue.SceneName?.Trim();
return !string.IsNullOrEmpty(sceneName) && TestSceneAllowlist.Contains(sceneName);
@@ -200,6 +207,8 @@ internal sealed record ValidatedPlayoutOptions(
}
}
var mutations = ResolveMutations(cue.Mutations);
var relativePath = cue.SceneFile?.Trim();
if (string.IsNullOrEmpty(relativePath) || Path.IsPathRooted(relativePath))
{
@@ -242,10 +251,226 @@ internal sealed record ValidatedPlayoutOptions(
return cue with
{
SceneFile = candidate,
SceneName = sceneName!
SceneName = sceneName!,
Mutations = mutations
};
}
private IReadOnlyList<PlayoutMutation>? ResolveMutations(
IReadOnlyList<PlayoutMutation>? mutations)
{
if (mutations is null)
{
return null;
}
if (mutations.Count > 100_000)
{
throw new PlayoutRequestException("장면 변경 항목이 너무 많습니다.");
}
var resolved = new PlayoutMutation[mutations.Count];
for (var index = 0; index < mutations.Count; index++)
{
resolved[index] = ResolveMutation(mutations[index]);
}
return resolved;
}
private PlayoutMutation ResolveMutation(PlayoutMutation? mutation)
{
if (mutation is null)
{
throw new PlayoutRequestException("장면 변경 항목이 올바르지 않습니다.");
}
switch (mutation)
{
case PlayoutSetValue value:
ValidateObjectName(value.ObjectName);
ValidateText(value.Value);
return value;
case PlayoutSetAssetValue asset:
ValidateObjectName(asset.ObjectName);
return asset with { AssetPath = ResolveAssetPath(asset.AssetPath) };
case PlayoutSetVisible visible:
ValidateObjectName(visible.ObjectName);
return visible;
case PlayoutSetFaceColor color:
ValidateObjectName(color.ObjectName);
if (!IsByte(color.Red) || !IsByte(color.Green) ||
!IsByte(color.Blue) || !IsByte(color.Alpha))
{
throw new PlayoutRequestException("장면 색상 값이 올바르지 않습니다.");
}
return color;
case PlayoutSetPosition position:
ValidateObjectName(position.ObjectName);
ValidatePoint(position.X, position.Y, position.Z);
ValidateVectorComponents(position.Components);
return position;
case PlayoutSetPositionKey positionKey:
ValidateObjectName(positionKey.ObjectName);
ValidateKeyIndex(positionKey.KeyIndex);
ValidatePoint(positionKey.X, positionKey.Y, positionKey.Z);
ValidateVectorComponents(positionKey.Components);
return positionKey;
case PlayoutSetScale scale:
ValidateObjectName(scale.ObjectName);
ValidatePoint(scale.X, scale.Y, scale.Z);
ValidateVectorComponents(scale.Components);
return scale;
case PlayoutSetCropKey crop:
ValidateObjectName(crop.ObjectName);
ValidateKeyIndex(crop.KeyIndex);
ValidatePoint(crop.Left, crop.Top, crop.Right);
if (!float.IsFinite(crop.Bottom) ||
!Enum.IsDefined(crop.Edges) || crop.Edges == 0)
{
throw new PlayoutRequestException("장면 Crop 값이 올바르지 않습니다.");
}
return crop;
case PlayoutSetCircleAngleKey angle:
ValidateObjectName(angle.ObjectName);
ValidateKeyIndex(angle.KeyIndex);
if (!float.IsFinite(angle.Start) || !float.IsFinite(angle.End) ||
!Enum.IsDefined(angle.Components) || angle.Components == 0)
{
throw new PlayoutRequestException("장면 각도 값이 올바르지 않습니다.");
}
return angle;
case PlayoutSetPathPoints path:
ValidateObjectName(path.ObjectName);
ValidatePoints(path.Points);
return path;
case PlayoutSetPathShapePoints shape:
ValidateObjectName(shape.ObjectName);
ValidatePoints(shape.Points);
return shape;
case PlayoutUseBackground background:
ValidateMutationTiming(background.Timing);
return background;
case PlayoutSetBackgroundTexture texture:
ValidateMutationTiming(texture.Timing);
return texture with { AssetPath = ResolveAssetPath(texture.AssetPath) };
case PlayoutSetBackgroundVideo video:
ValidateMutationTiming(video.Timing);
// The legacy MainForm intentionally uses 2004 for the common studio
// background. Keep the input bounded while retaining that vendor value.
if (video.LoopCount is < 0 or > 10_000)
{
throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다.");
}
return video with { AssetPath = ResolveAssetPath(video.AssetPath) };
default:
throw new PlayoutRequestException("지원하지 않는 장면 변경 항목입니다.");
}
}
private string ResolveAssetPath(string? relativePath)
{
relativePath = relativePath?.Trim();
if (SceneDirectory is null || string.IsNullOrEmpty(relativePath) ||
Path.IsPathRooted(relativePath) || relativePath.Contains("..", StringComparison.Ordinal) ||
relativePath.Any(char.IsControl))
{
throw new PlayoutRequestException("장면 자산 경로가 올바르지 않습니다.");
}
var extension = Path.GetExtension(relativePath);
string[] allowedExtensions =
[
".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds",
".vrv", ".avi", ".mp4", ".mov"
];
if (!allowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
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 candidate;
}
private static void ValidateObjectName(string? objectName)
{
if (string.IsNullOrWhiteSpace(objectName) || objectName.Length > 128 ||
objectName.Any(char.IsControl))
{
throw new PlayoutRequestException("장면 오브젝트 이름이 올바르지 않습니다.");
}
}
private static void ValidateText(string? value)
{
if (value is null || value.Length > 16_384 || value.Any(character =>
character is '\0' or '\u0001' or '\u0002' or '\u0003'))
{
throw new PlayoutRequestException("장면 텍스트 값이 올바르지 않습니다.");
}
}
private static void ValidatePoint(float x, float y, float z)
{
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z))
{
throw new PlayoutRequestException("장면 좌표 값이 올바르지 않습니다.");
}
}
private static void ValidatePoints(IReadOnlyList<PlayoutPoint>? points)
{
if (points is null || points.Count > 10_000)
{
throw new PlayoutRequestException("장면 그래프 점 목록이 올바르지 않습니다.");
}
foreach (var point in points)
{
ValidatePoint(point.X, point.Y, point.Z);
}
}
private static void ValidateVectorComponents(PlayoutVectorComponents components)
{
if (!Enum.IsDefined(components) || components == 0)
{
throw new PlayoutRequestException("장면 벡터 축 값이 올바르지 않습니다.");
}
}
private static void ValidateKeyIndex(int keyIndex)
{
if (keyIndex is < 0 or > 10_000)
{
throw new PlayoutRequestException("장면 키 인덱스가 올바르지 않습니다.");
}
}
private static void ValidateMutationTiming(PlayoutMutationTiming timing)
{
if (!Enum.IsDefined(timing))
{
throw new PlayoutRequestException("장면 변경 시점이 올바르지 않습니다.");
}
}
private static bool IsByte(int value) => value is >= byte.MinValue and <= byte.MaxValue;
private static bool CanMatchProgramTitle(Regex regex)
{
string[] protectedTitles =

View File

@@ -302,6 +302,10 @@ internal static class InstalledK3dInteropMetadata
assembly,
"K3DAsyncEngineLib.KAEventHandlerClass",
K3dComConstants.KaEventHandlerClassGuid);
var eventHandlerInterfaceType = RequireComType(
assembly,
"K3DAsyncEngineLib.KAEventHandler",
Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832"));
var scenePlayerType = RequireComType(
assembly,
"K3DAsyncEngineLib.IKAScenePlayer",
@@ -315,6 +319,22 @@ internal static class InstalledK3dInteropMetadata
"K3DAsyncEngineLib.IKAObject",
Guid.Parse("B138B515-505B-4C7B-A3E1-F82782005ECF"));
var callbackMethods = eventHandlerInterfaceType.GetInterfaces()
.Append(eventHandlerInterfaceType)
.SelectMany(type => type.GetMethods(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
.Distinct()
.ToArray();
if (callbackMethods.Length != 282 ||
callbackMethods.Any(method => method.ReturnType != typeof(void)) ||
!HasCallback(callbackMethods, "OnHello") ||
!HasCallback(callbackMethods, "OnScenePlayed", typeof(int), typeof(int), typeof(int)) ||
!HasCallback(callbackMethods, "OnCutOut", typeof(int), typeof(int), typeof(int)) ||
!HasCallback(callbackMethods, "OnStopAll", typeof(int)))
{
throw new InstalledK3dInteropMetadataUnavailableException();
}
var connectMethod = engineType.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.SingleOrDefault(method =>
method.Name == "KTAPConnect" &&
@@ -326,6 +346,7 @@ internal static class InstalledK3dInteropMetadata
parameters[3].ParameterType == typeof(int) &&
parameters[4].ParameterType.GUID ==
Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832") &&
parameters[4].ParameterType == eventHandlerInterfaceType &&
parameters[4].ParameterType.IsAssignableFrom(eventHandlerType));
var disconnectMethod = engineType.GetMethod(
"Disconnect",
@@ -365,6 +386,7 @@ internal static class InstalledK3dInteropMetadata
return new InstalledK3dInteropTypes(
engineType,
eventHandlerType,
eventHandlerInterfaceType,
scenePlayerType,
sceneType,
objectType,
@@ -384,6 +406,14 @@ internal static class InstalledK3dInteropMetadata
return type;
}
private static bool HasCallback(
IEnumerable<MethodInfo> methods,
string name,
params Type[] parameterTypes) => methods.Any(method =>
method.Name == name &&
method.GetParameters().Select(parameter => parameter.ParameterType)
.SequenceEqual(parameterTypes));
private static bool IsHex(char value) =>
value is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
}
@@ -416,6 +446,7 @@ internal sealed class InstalledK3dInteropFileLease
internal sealed record InstalledK3dInteropTypes(
Type EngineType,
Type EventHandlerType,
Type EventHandlerInterfaceType,
Type ScenePlayerType,
Type SceneType,
Type ObjectType,
@@ -468,6 +499,9 @@ internal sealed class InstalledK3dInteropMethodInvoker : ILateBoundComMethodInvo
[
"SetSceneEffectType",
"SetOutputChannelIndex",
"SetBackgroundTexture",
"SetBackgroundVideo",
"UseBackground",
"QueryVariables",
"GetObject",
"Unload"
@@ -476,7 +510,21 @@ internal sealed class InstalledK3dInteropMethodInvoker : ILateBoundComMethodInvo
private static readonly IReadOnlySet<string> ObjectMethods = new HashSet<string>(
[
"SetValue",
"SetVisible"
"SetVisible",
"SetFaceColor",
"SetPosition",
"SetPositionKey",
"SetScale",
"SetCropKey",
"SetCircleAngleKey",
"BeginPathPoint",
"ClearPathPoints",
"AddPathPoint",
"EndPathPoint",
"BeginPathShapePoint",
"ClearPathShapePoints",
"AddPathShapePoint",
"EndPathShapePoint"
], StringComparer.Ordinal);
public object? Invoke(object target, string method, params object?[] arguments)

View File

@@ -712,6 +712,7 @@ internal sealed class PgmCutsSequenceRunner
OutputChannel = null,
LayoutIndex = K3dComConstants.LegacyLayoutIndex,
SceneDirectory = request.SceneRoot,
TestSceneAllowlist = [FirstSceneCode, NextSceneCode],
TrustedLiveOutputEnabled = true,
QueueCapacity = 8,
ConnectTimeoutMilliseconds = 5_000,

View File

@@ -0,0 +1,349 @@
using System.Collections.Concurrent;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
namespace MBN_STOCK_WEBVIEW.Playout.Interop;
internal enum K3dLifecycleCallbackKind
{
ScenePlayed,
CutOut,
StopAll
}
internal readonly record struct K3dLifecycleCallback(
K3dLifecycleCallbackKind Kind,
bool IsSuccess,
int? OutputChannelIndex,
int? LayerIndex);
public interface IK3dEventSink
{
void OnCallback(string methodName, object?[] arguments);
}
internal interface IK3dEventHandlerFactory
{
bool SupportsCallbacks { get; }
object Create(IK3dEventSink sink);
}
internal sealed class ActivatorK3dEventHandlerFactory(ILateBoundComActivator activator)
: IK3dEventHandlerFactory
{
private readonly ILateBoundComActivator _activator =
activator ?? throw new ArgumentNullException(nameof(activator));
public bool SupportsCallbacks => false;
public object Create(IK3dEventSink sink)
{
ArgumentNullException.ThrowIfNull(sink);
return _activator.Create(K3dComConstants.KaEventHandlerClassGuid);
}
}
internal sealed class InstalledK3dEventHandlerFactory : IK3dEventHandlerFactory
{
public bool SupportsCallbacks => true;
public object Create(IK3dEventSink sink)
{
ArgumentNullException.ThrowIfNull(sink);
var interfaceType = InstalledK3dInteropMetadata.Resolve().EventHandlerInterfaceType;
return DynamicK3dEventHandlerBuilder.Create(interfaceType, sink);
}
}
internal sealed class K3dEventQueue : IK3dEventSink
{
private readonly ConcurrentQueue<K3dLifecycleCallback> _callbacks = new();
private readonly int _capacity;
private int _count;
private int _overflowed;
private int _helloObserved;
public K3dEventQueue(int capacity = 256)
{
if (capacity < 1)
{
throw new ArgumentOutOfRangeException(nameof(capacity));
}
_capacity = capacity;
}
public bool HelloObserved => Volatile.Read(ref _helloObserved) != 0;
public bool HasOverflowed => Volatile.Read(ref _overflowed) != 0;
public int PendingCount => Volatile.Read(ref _count);
public void OnCallback(string methodName, object?[] arguments)
{
// Nothing may escape across the native callback boundary.
try
{
if (string.Equals(methodName, "OnHello", StringComparison.Ordinal))
{
Volatile.Write(ref _helloObserved, 1);
return;
}
K3dLifecycleCallback callback;
if (string.Equals(methodName, "OnScenePlayed", StringComparison.Ordinal) &&
TryReadInt(arguments, 0, out var playSuccess) &&
TryReadInt(arguments, 1, out var playChannel) &&
TryReadInt(arguments, 2, out var playLayer))
{
callback = new K3dLifecycleCallback(
K3dLifecycleCallbackKind.ScenePlayed,
playSuccess != 0,
playChannel,
playLayer);
}
else if (string.Equals(methodName, "OnCutOut", StringComparison.Ordinal) &&
TryReadInt(arguments, 0, out var cutSuccess) &&
TryReadInt(arguments, 1, out var cutChannel) &&
TryReadInt(arguments, 2, out var cutLayer))
{
callback = new K3dLifecycleCallback(
K3dLifecycleCallbackKind.CutOut,
cutSuccess != 0,
cutChannel,
cutLayer);
}
else if (string.Equals(methodName, "OnStopAll", StringComparison.Ordinal) &&
TryReadInt(arguments, 0, out var stopSuccess))
{
callback = new K3dLifecycleCallback(
K3dLifecycleCallbackKind.StopAll,
stopSuccess != 0,
null,
null);
}
else
{
return;
}
var count = Interlocked.Increment(ref _count);
if (count > _capacity)
{
Interlocked.Decrement(ref _count);
Volatile.Write(ref _overflowed, 1);
return;
}
_callbacks.Enqueue(callback);
}
catch
{
Volatile.Write(ref _overflowed, 1);
}
}
public bool TryDequeue(out K3dLifecycleCallback callback)
{
if (!_callbacks.TryDequeue(out callback))
{
return false;
}
Interlocked.Decrement(ref _count);
return true;
}
private static bool TryReadInt(object?[] arguments, int index, out int value)
{
value = 0;
if ((uint)index >= (uint)arguments.Length || arguments[index] is null)
{
return false;
}
try
{
value = Convert.ToInt32(
arguments[index],
System.Globalization.CultureInfo.InvariantCulture);
return true;
}
catch
{
return false;
}
}
}
internal static class DynamicK3dEventHandlerBuilder
{
private static readonly ConcurrentDictionary<Type, ConstructorInfo> Constructors = new();
private static readonly MethodInfo SinkCallbackMethod = typeof(IK3dEventSink).GetMethod(
nameof(IK3dEventSink.OnCallback),
BindingFlags.Public | BindingFlags.Instance)
?? throw new MissingMethodException(nameof(IK3dEventSink.OnCallback));
private static readonly IReadOnlySet<string> ForwardedCallbacks = new HashSet<string>(
[
"OnHello",
"OnScenePlayed",
"OnCutOut",
"OnStopAll"
], StringComparer.Ordinal);
public static object Create(Type eventHandlerInterface, IK3dEventSink sink)
{
ArgumentNullException.ThrowIfNull(eventHandlerInterface);
ArgumentNullException.ThrowIfNull(sink);
if (!eventHandlerInterface.IsInterface || !eventHandlerInterface.IsImport ||
eventHandlerInterface.GUID != Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832"))
{
throw new InvalidOperationException("The installed K3D callback interface is invalid.");
}
var constructor = Constructors.GetOrAdd(eventHandlerInterface, BuildConstructor);
return constructor.Invoke([sink]);
}
private static ConstructorInfo BuildConstructor(Type eventHandlerInterface)
{
var assembly = AssemblyBuilder.DefineDynamicAssembly(
new AssemblyName($"MBN.K3D.Callback.{Guid.NewGuid():N}"),
AssemblyBuilderAccess.RunAndCollect);
var module = assembly.DefineDynamicModule("Callbacks");
var type = module.DefineType(
$"K3dEventHandler_{Guid.NewGuid():N}",
TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class);
type.AddInterfaceImplementation(eventHandlerInterface);
var comVisible = typeof(ComVisibleAttribute).GetConstructor([typeof(bool)])!;
type.SetCustomAttribute(new CustomAttributeBuilder(comVisible, [true]));
var classInterface = typeof(ClassInterfaceAttribute).GetConstructor([typeof(ClassInterfaceType)])!;
type.SetCustomAttribute(new CustomAttributeBuilder(
classInterface,
[ClassInterfaceType.None]));
var sinkField = type.DefineField(
"_sink",
typeof(IK3dEventSink),
FieldAttributes.Private | FieldAttributes.InitOnly);
var constructor = type.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
[typeof(IK3dEventSink)]);
var constructorIl = constructor.GetILGenerator();
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)!);
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Ldarg_1);
constructorIl.Emit(OpCodes.Stfld, sinkField);
constructorIl.Emit(OpCodes.Ret);
var interfaces = eventHandlerInterface.GetInterfaces().Append(eventHandlerInterface);
var methods = interfaces
.SelectMany(item => item.GetMethods(
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
.Distinct()
.ToArray();
if (methods.Length == 0 || methods.Any(method => method.ReturnType != typeof(void)))
{
throw new InvalidOperationException("The installed K3D callback surface is unexpected.");
}
foreach (var method in methods)
{
ImplementMethod(type, sinkField, method);
}
var generated = type.CreateType()
?? throw new InvalidOperationException("The K3D callback adapter could not be generated.");
return generated.GetConstructor([typeof(IK3dEventSink)])
?? throw new MissingMethodException("The K3D callback adapter constructor is missing.");
}
private static void ImplementMethod(
TypeBuilder type,
FieldBuilder sinkField,
MethodInfo interfaceMethod)
{
var parameters = interfaceMethod.GetParameters();
var method = type.DefineMethod(
interfaceMethod.Name,
MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.Final |
MethodAttributes.HideBySig | MethodAttributes.NewSlot,
CallingConventions.Standard,
typeof(void),
parameters.Select(parameter => parameter.ParameterType).ToArray());
for (var index = 0; index < parameters.Length; index++)
{
method.DefineParameter(index + 1, parameters[index].Attributes, parameters[index].Name);
}
var il = method.GetILGenerator();
if (ForwardedCallbacks.Contains(interfaceMethod.Name))
{
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldfld, sinkField);
il.Emit(OpCodes.Ldstr, interfaceMethod.Name);
EmitInt(il, parameters.Length);
il.Emit(OpCodes.Newarr, typeof(object));
for (var index = 0; index < parameters.Length; index++)
{
il.Emit(OpCodes.Dup);
EmitInt(il, index);
EmitLoadArgument(il, index + 1);
var parameterType = parameters[index].ParameterType;
if (parameterType.IsByRef)
{
parameterType = parameterType.GetElementType()
?? throw new InvalidOperationException("The K3D callback parameter is invalid.");
il.Emit(OpCodes.Ldobj, parameterType);
}
if (parameterType.IsValueType)
{
il.Emit(OpCodes.Box, parameterType);
}
il.Emit(OpCodes.Stelem_Ref);
}
il.Emit(OpCodes.Callvirt, SinkCallbackMethod);
}
il.Emit(OpCodes.Ret);
type.DefineMethodOverride(method, interfaceMethod);
}
private static void EmitLoadArgument(ILGenerator il, int index)
{
switch (index)
{
case 0: il.Emit(OpCodes.Ldarg_0); break;
case 1: il.Emit(OpCodes.Ldarg_1); break;
case 2: il.Emit(OpCodes.Ldarg_2); break;
case 3: il.Emit(OpCodes.Ldarg_3); break;
default: il.Emit(OpCodes.Ldarg, index); break;
}
}
private static void EmitInt(ILGenerator il, int value)
{
switch (value)
{
case 0: il.Emit(OpCodes.Ldc_I4_0); break;
case 1: il.Emit(OpCodes.Ldc_I4_1); break;
case 2: il.Emit(OpCodes.Ldc_I4_2); break;
case 3: il.Emit(OpCodes.Ldc_I4_3); break;
case 4: il.Emit(OpCodes.Ldc_I4_4); break;
case 5: il.Emit(OpCodes.Ldc_I4_5); break;
case 6: il.Emit(OpCodes.Ldc_I4_6); break;
case 7: il.Emit(OpCodes.Ldc_I4_7); break;
case 8: il.Emit(OpCodes.Ldc_I4_8); break;
default: il.Emit(OpCodes.Ldc_I4, value); break;
}
}
}

View File

@@ -14,6 +14,14 @@ internal interface IK3dSession : IDisposable
PlayoutKtapConnectState LastKtapConnectState { get; }
bool SupportsLifecycleCallbacks { get; }
bool? KtapHelloObserved { get; }
bool HasPendingLifecycleCallbacks { get; }
bool HasPendingPlayCallbacks { get; }
bool TryPreventKtapConnect();
void Connect(ValidatedPlayoutOptions options);
@@ -22,13 +30,22 @@ internal interface IK3dSession : IDisposable
void Prepare(PlayoutCue cue, int layoutIndex);
void UpdateOnAir(PlayoutCue cue, int layoutIndex);
void Play(int layoutIndex);
void TakeOut(int layoutIndex, PlayoutTakeOutScope scope);
K3dCallbackDrainResult ProcessPendingCallbacks(int layoutIndex);
void Abandon();
}
internal readonly record struct K3dCallbackDrainResult(
int CallbackCount,
int UnloadedSceneCount,
bool HelloObserved);
internal interface IK3dGuardedConnectSession : IK3dSession
{
void ConnectGuarded(
@@ -51,31 +68,46 @@ internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
{
private readonly ILateBoundComActivator _activator;
private readonly ILateBoundComMethodInvoker _invoker;
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
public DynamicK3dSessionFactory()
: this(
new InstalledK3dInteropComActivator(),
new InstalledK3dInteropMethodInvoker())
new InstalledK3dInteropMethodInvoker(),
new InstalledK3dEventHandlerFactory())
{
}
internal DynamicK3dSessionFactory(ILateBoundComActivator activator)
: this(activator, new InstalledK3dInteropMethodInvoker())
: this(
activator,
new InstalledK3dInteropMethodInvoker(),
new ActivatorK3dEventHandlerFactory(activator))
{
}
internal DynamicK3dSessionFactory(
ILateBoundComActivator activator,
ILateBoundComMethodInvoker invoker)
: this(activator, invoker, new ActivatorK3dEventHandlerFactory(activator))
{
}
internal DynamicK3dSessionFactory(
ILateBoundComActivator activator,
ILateBoundComMethodInvoker invoker,
IK3dEventHandlerFactory eventHandlerFactory)
{
_activator = activator;
_invoker = invoker;
_eventHandlerFactory = eventHandlerFactory;
}
public IK3dSession Create() => new DynamicK3dSession(
_activator,
new RuntimeComObjectReleaser(),
_invoker);
_invoker,
_eventHandlerFactory);
}
internal interface ILateBoundComActivator
@@ -120,23 +152,30 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
private readonly ILateBoundComActivator _activator;
private readonly ILateBoundComObjectReleaser _releaser;
private readonly ILateBoundComMethodInvoker _invoker;
private readonly IK3dEventHandlerFactory _eventHandlerFactory;
private readonly K3dEventQueue _eventQueue = new();
private object? _engine;
private object? _eventHandler;
private object? _player;
private object? _preparedScene;
private object? _currentScene;
private readonly List<object> _retiredScenes = [];
private readonly List<object> _pendingTakeOutScenes = [];
private int? _outputChannel;
private int? _ownerThreadId;
private int _lastKtapConnectState;
private int _ktapDispatchGate;
private bool _disposed;
private int _pendingPlayCallbacks;
private int _pendingCutOutCallbacks;
private int _pendingStopAllCallbacks;
public DynamicK3dSession(ILateBoundComActivator activator)
: this(
activator,
new RuntimeComObjectReleaser(),
new InstalledK3dInteropMethodInvoker())
new InstalledK3dInteropMethodInvoker(),
new ActivatorK3dEventHandlerFactory(activator))
{
}
@@ -151,10 +190,24 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
ILateBoundComActivator activator,
ILateBoundComObjectReleaser releaser,
ILateBoundComMethodInvoker invoker)
: this(
activator,
releaser,
invoker,
new ActivatorK3dEventHandlerFactory(activator))
{
}
internal DynamicK3dSession(
ILateBoundComActivator activator,
ILateBoundComObjectReleaser releaser,
ILateBoundComMethodInvoker invoker,
IK3dEventHandlerFactory eventHandlerFactory)
{
_activator = activator;
_releaser = releaser;
_invoker = invoker;
_eventHandlerFactory = eventHandlerFactory;
}
public bool IsConnected => _engine is not null && _player is not null;
@@ -162,6 +215,21 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
public PlayoutKtapConnectState LastKtapConnectState =>
(PlayoutKtapConnectState)Volatile.Read(ref _lastKtapConnectState);
public bool SupportsLifecycleCallbacks => _eventHandlerFactory.SupportsCallbacks;
public bool? KtapHelloObserved => SupportsLifecycleCallbacks
? _eventQueue.HelloObserved
: null;
public bool HasPendingLifecycleCallbacks => SupportsLifecycleCallbacks &&
(_pendingPlayCallbacks != 0 ||
_pendingCutOutCallbacks != 0 ||
_pendingStopAllCallbacks != 0 ||
_eventQueue.PendingCount != 0);
public bool HasPendingPlayCallbacks =>
SupportsLifecycleCallbacks && _pendingPlayCallbacks != 0;
public bool TryPreventKtapConnect()
{
var previous = Interlocked.CompareExchange(ref _ktapDispatchGate, 2, 0);
@@ -210,7 +278,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
var ktapConnectAttempted = false;
try
{
_eventHandler = _activator.Create(K3dComConstants.KaEventHandlerClassGuid);
_eventHandler = _eventHandlerFactory.Create(_eventQueue);
_engine = _activator.Create(K3dComConstants.KaEngineClassGuid);
if (finalSafetyCheck is not null && !finalSafetyCheck())
{
@@ -358,6 +426,14 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
K3dComConstants.SceneChangeEffectFade,
cue.FadeDuration);
foreach (var mutation in cue.Mutations ?? [])
{
if (IsBeforeTransactionSceneSetupMutation(mutation))
{
ApplySceneSetupMutation(nextScene, mutation);
}
}
Invoke(engine, "BeginTransaction");
transactionStarted = true;
foreach (var field in cue.Fields ?? [])
@@ -365,6 +441,16 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
ApplyField(nextScene, field);
}
foreach (var mutation in cue.Mutations ?? [])
{
if (!IsBeforeTransactionSceneSetupMutation(mutation))
{
ApplyTransactionMutation(nextScene, mutation);
}
}
Invoke(nextScene, "QueryVariables");
if (outputChannel.HasValue)
{
Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault());
@@ -375,7 +461,6 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
transactionStarted = false;
Invoke(nextScene, "QueryVariables");
Invoke(player, "Prepare", layoutIndex, nextScene);
var replacedPreparedScene = _preparedScene;
@@ -409,7 +494,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
{
EnsureThread();
EnsureConnected();
Invoke(_player!, "Play", layoutIndex);
InvokeTrackedPlay(layoutIndex);
var preparedScene = _preparedScene;
if (preparedScene is not null)
@@ -426,26 +511,252 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
private void InvokeTrackedPlay(int layoutIndex)
{
if (SupportsLifecycleCallbacks)
{
_pendingPlayCallbacks++;
}
try
{
Invoke(_player!, "Play", layoutIndex);
}
catch
{
if (SupportsLifecycleCallbacks)
{
_pendingPlayCallbacks--;
}
throw;
}
}
public void UpdateOnAir(PlayoutCue cue, int layoutIndex)
{
ArgumentNullException.ThrowIfNull(cue);
EnsureThread();
EnsureConnected();
var engine = _engine!;
var player = _player!;
var outputChannel = _outputChannel;
object? playingScene = null;
var transactionStarted = false;
try
{
// MainForm.timer1_Tick replays the current layout before entering
// Show_PlayList(idx: 1), then prepares and plays the mutated scene again.
InvokeTrackedPlay(layoutIndex);
playingScene = InvokeRequired(player, "GetPlayingScene", layoutIndex);
Invoke(engine, "BeginTransaction");
transactionStarted = true;
foreach (var field in cue.Fields ?? [])
{
ApplyField(playingScene, field);
}
foreach (var mutation in cue.Mutations ?? [])
{
// The legacy idx == 1 path updates the playing scene in place. It does not
// reapply scene-level background setup or the scene transition effect.
if (!IsSceneLevelMutation(mutation))
{
ApplyMutation(playingScene, mutation);
}
}
Invoke(playingScene, "QueryVariables");
if (outputChannel.HasValue)
{
Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault());
}
else
{
Invoke(engine, "EndTransaction");
}
transactionStarted = false;
Invoke(player, "Prepare", layoutIndex, playingScene);
InvokeTrackedPlay(layoutIndex);
// GetPlayingScene can return the same RCW or another RCW for the same COM
// scene. An in-place page update must never retire/unload the active scene.
if (_currentScene is null)
{
_currentScene = playingScene;
playingScene = null;
}
else if (ReferenceEquals(_currentScene, playingScene))
{
playingScene = null;
}
}
catch
{
if (transactionStarted)
{
try
{
Invoke(engine, "RollbackTransaction");
}
catch
{
// Preserve the original SDK failure. The engine quarantines this session.
}
}
throw;
}
finally
{
ReleaseComObject(playingScene);
}
}
public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope)
{
EnsureThread();
EnsureConnected();
if (scope == PlayoutTakeOutScope.All)
if (SupportsLifecycleCallbacks)
{
Invoke(_player!, "StopAll");
if (scope == PlayoutTakeOutScope.All)
{
_pendingStopAllCallbacks++;
}
else
{
_pendingCutOutCallbacks++;
}
}
try
{
if (scope == PlayoutTakeOutScope.All)
{
Invoke(_player!, "StopAll");
}
else
{
Invoke(_player!, "CutOut", layoutIndex);
}
}
catch
{
if (SupportsLifecycleCallbacks)
{
if (scope == PlayoutTakeOutScope.All)
{
_pendingStopAllCallbacks--;
}
else
{
_pendingCutOutCallbacks--;
}
}
throw;
}
if (SupportsLifecycleCallbacks)
{
MoveAllScenesToPendingTakeOut();
}
else
{
Invoke(_player!, "CutOut", layoutIndex);
var currentScene = _currentScene;
_currentScene = null;
if (currentScene is not null)
{
RetainRetiredScene(currentScene);
}
}
var currentScene = _currentScene;
_currentScene = null;
if (currentScene is not null)
}
public K3dCallbackDrainResult ProcessPendingCallbacks(int layoutIndex)
{
EnsureThread();
EnsureConnected();
if (!SupportsLifecycleCallbacks)
{
RetainRetiredScene(currentScene);
return new K3dCallbackDrainResult(0, 0, false);
}
if (_eventQueue.HasOverflowed)
{
throw new InvalidOperationException("The K3D callback queue overflowed.");
}
var callbackCount = 0;
var unloadedSceneCount = 0;
while (_eventQueue.TryDequeue(out var callback))
{
callbackCount++;
switch (callback.Kind)
{
case K3dLifecycleCallbackKind.ScenePlayed:
if (_pendingPlayCallbacks == 0 ||
!MatchesOutput(callback, layoutIndex))
{
continue;
}
_pendingPlayCallbacks--;
if (!callback.IsSuccess)
{
throw new InvalidOperationException("The K3D play callback reported failure.");
}
unloadedSceneCount += UnloadAndRelease(_retiredScenes);
break;
case K3dLifecycleCallbackKind.CutOut:
if (_pendingCutOutCallbacks == 0 ||
!MatchesOutput(callback, layoutIndex))
{
continue;
}
_pendingCutOutCallbacks--;
if (!callback.IsSuccess)
{
throw new InvalidOperationException("The K3D cut-out callback reported failure.");
}
// CutOut terminates the selected layer. Any Play completion for that
// layer which has not arrived yet can no longer be required for safe
// lifetime accounting.
_pendingPlayCallbacks = 0;
unloadedSceneCount += UnloadAndRelease(_pendingTakeOutScenes);
break;
case K3dLifecycleCallbackKind.StopAll:
if (_pendingStopAllCallbacks == 0)
{
continue;
}
_pendingStopAllCallbacks--;
if (!callback.IsSuccess)
{
throw new InvalidOperationException("The K3D stop-all callback reported failure.");
}
// StopAll terminates every layer owned by this player. A stopped Play
// is not guaranteed to produce a later OnScenePlayed callback.
_pendingPlayCallbacks = 0;
unloadedSceneCount += UnloadAndRelease(_pendingTakeOutScenes);
break;
default:
throw new InvalidOperationException("The K3D callback is not allowlisted.");
}
}
return new K3dCallbackDrainResult(
callbackCount,
unloadedSceneCount,
_eventQueue.HelloObserved);
}
public void Abandon()
@@ -513,6 +824,180 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
}
private void ApplySceneSetupMutation(object scene, PlayoutMutation mutation)
{
switch (mutation)
{
case PlayoutUseBackground background:
Invoke(scene, "UseBackground", background.IsEnabled ? 1 : 0);
break;
case PlayoutSetBackgroundTexture texture:
Invoke(scene, "SetBackgroundTexture", texture.AssetPath);
break;
case PlayoutSetBackgroundVideo video:
Invoke(
scene,
"SetBackgroundVideo",
video.AssetPath,
video.LoopCount,
video.LoopInfinite ? 1 : 0);
break;
default:
throw new InvalidOperationException("The scene setup mutation is not allowlisted.");
}
}
private void ApplyTransactionMutation(object scene, PlayoutMutation mutation)
{
if (IsSceneLevelMutation(mutation))
{
ApplySceneSetupMutation(scene, mutation);
return;
}
ApplyMutation(scene, mutation);
}
private static bool IsSceneLevelMutation(PlayoutMutation mutation) => mutation is
PlayoutUseBackground or PlayoutSetBackgroundTexture or PlayoutSetBackgroundVideo;
private static bool IsBeforeTransactionSceneSetupMutation(PlayoutMutation mutation) =>
mutation switch
{
PlayoutUseBackground background =>
background.Timing == PlayoutMutationTiming.BeforeTransaction,
PlayoutSetBackgroundTexture texture =>
texture.Timing == PlayoutMutationTiming.BeforeTransaction,
PlayoutSetBackgroundVideo video =>
video.Timing == PlayoutMutationTiming.BeforeTransaction,
_ => false
};
private void ApplyMutation(object scene, PlayoutMutation mutation)
{
var objectName = mutation switch
{
PlayoutSetValue value => value.ObjectName,
PlayoutSetAssetValue value => value.ObjectName,
PlayoutSetVisible visible => visible.ObjectName,
PlayoutSetFaceColor color => color.ObjectName,
PlayoutSetPosition position => position.ObjectName,
PlayoutSetPositionKey positionKey => positionKey.ObjectName,
PlayoutSetScale scale => scale.ObjectName,
PlayoutSetCropKey crop => crop.ObjectName,
PlayoutSetCircleAngleKey angle => angle.ObjectName,
PlayoutSetPathPoints path => path.ObjectName,
PlayoutSetPathShapePoints shape => shape.ObjectName,
_ => throw new InvalidOperationException("The scene mutation is not allowlisted.")
};
var sceneObject = InvokeRequired(scene, "GetObject", objectName);
try
{
switch (mutation)
{
case PlayoutSetValue value:
Invoke(sceneObject, "SetValue", value.Value);
break;
case PlayoutSetAssetValue asset:
Invoke(sceneObject, "SetValue", asset.AssetPath);
break;
case PlayoutSetVisible visible:
Invoke(sceneObject, "SetVisible", visible.IsVisible ? 1 : 0);
break;
case PlayoutSetFaceColor color:
Invoke(
sceneObject,
"SetFaceColor",
color.Red,
color.Green,
color.Blue,
color.Alpha);
break;
case PlayoutSetPosition position:
Invoke(
sceneObject,
"SetPosition",
position.X,
position.Y,
position.Z,
(int)position.Components);
break;
case PlayoutSetPositionKey positionKey:
Invoke(
sceneObject,
"SetPositionKey",
positionKey.KeyIndex,
positionKey.X,
positionKey.Y,
positionKey.Z,
(int)positionKey.Components);
break;
case PlayoutSetScale scale:
Invoke(
sceneObject,
"SetScale",
scale.X,
scale.Y,
scale.Z,
(int)scale.Components);
break;
case PlayoutSetCropKey crop:
Invoke(
sceneObject,
"SetCropKey",
crop.KeyIndex,
crop.Left,
crop.Top,
crop.Right,
crop.Bottom,
(int)crop.Edges);
break;
case PlayoutSetCircleAngleKey angle:
Invoke(
sceneObject,
"SetCircleAngleKey",
angle.KeyIndex,
angle.Start,
angle.End,
(int)angle.Components);
break;
case PlayoutSetPathPoints path:
ReplacePathPoints(sceneObject, path.Points, shape: false);
break;
case PlayoutSetPathShapePoints shape:
ReplacePathPoints(sceneObject, shape.Points, shape: true);
break;
default:
throw new InvalidOperationException("The scene mutation is not allowlisted.");
}
}
finally
{
ReleaseComObject(sceneObject);
}
}
private void ReplacePathPoints(
object sceneObject,
IReadOnlyList<PlayoutPoint> points,
bool shape)
{
var beginMethod = shape ? "BeginPathShapePoint" : "BeginPathPoint";
var clearMethod = shape ? "ClearPathShapePoints" : "ClearPathPoints";
var addMethod = shape ? "AddPathShapePoint" : "AddPathPoint";
var endMethod = shape ? "EndPathShapePoint" : "EndPathPoint";
Invoke(sceneObject, beginMethod);
Invoke(sceneObject, clearMethod);
foreach (var point in points)
{
Invoke(sceneObject, addMethod, point.X, point.Y, point.Z);
}
Invoke(sceneObject, endMethod);
}
private void EnsureThread()
{
var currentThreadId = Environment.CurrentManagedThreadId;
@@ -555,6 +1040,15 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
_retiredScenes.Clear();
foreach (var pendingTakeOutScene in _pendingTakeOutScenes)
{
AddDistinctScene(scenes, pendingTakeOutScene);
}
_pendingTakeOutScenes.Clear();
_pendingPlayCallbacks = 0;
_pendingCutOutCallbacks = 0;
_pendingStopAllCallbacks = 0;
var player = _player;
_player = null;
_outputChannel = null;
@@ -584,6 +1078,42 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
}
private void MoveAllScenesToPendingTakeOut()
{
AddDistinctScene(_pendingTakeOutScenes, _preparedScene);
_preparedScene = null;
AddDistinctScene(_pendingTakeOutScenes, _currentScene);
_currentScene = null;
foreach (var retiredScene in _retiredScenes)
{
AddDistinctScene(_pendingTakeOutScenes, retiredScene);
}
_retiredScenes.Clear();
}
private int UnloadAndRelease(List<object> scenes)
{
var unloaded = 0;
while (scenes.Count > 0)
{
var scene = scenes[0];
Invoke(scene, "Unload");
scenes.RemoveAt(0);
ReleaseComObject(scene);
unloaded++;
}
return unloaded;
}
private bool MatchesOutput(K3dLifecycleCallback callback, int layoutIndex)
{
var expectedChannel = _outputChannel ?? 0;
return callback.OutputChannelIndex == expectedChannel &&
callback.LayerIndex == layoutIndex;
}
private static void AddDistinctScene(List<object> scenes, object? scene)
{
if (scene is not null && !scenes.Any(item => ReferenceEquals(item, scene)))

View File

@@ -37,6 +37,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private int _reconnectAttempts;
private long _sequence;
private int _lastKtapConnectState;
private int _ktapHelloObservedState;
private bool _connectionRequested;
private volatile bool _outcomeUnknown;
private volatile bool _quarantineIncomplete;
@@ -118,6 +119,17 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
token => NextCoreAsync(cue, token));
}
public Task<PlayoutResult> UpdateOnAirAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cue);
return SerializedAsync(
PlayoutOperation.UpdateOnAir,
cancellationToken,
token => UpdateOnAirCoreAsync(cue, token));
}
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default) =>
@@ -190,7 +202,18 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
if (_session is not null && _dispatcher is not null && !_dispatcher.IsQuarantined)
{
if (_onAirSceneName is not null || _outcomeUnknown)
if (_session.HasPendingLifecycleCallbacks &&
!await ProcessLifecycleCallbacksAsync(CancellationToken.None).ConfigureAwait(false))
{
_outcomeUnknown = true;
}
if (_session?.HasPendingLifecycleCallbacks == true)
{
_outcomeUnknown = true;
await AbandonCurrentSessionAsync().ConfigureAwait(false);
}
else if (_onAirSceneName is not null || _outcomeUnknown)
{
_outcomeUnknown = true;
await AbandonCurrentSessionAsync().ConfigureAwait(false);
@@ -307,6 +330,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
: PlayoutConnectionState.Disconnected;
}
if (_session is not null &&
!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false))
{
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled ||
_outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts ||
_timeProvider.GetUtcNow() < _nextReconnectAt)
@@ -702,6 +732,26 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
if (_session?.HasPendingLifecycleCallbacks == true)
{
if (!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
if (_session?.HasPendingLifecycleCallbacks == true)
{
_connectionState = PlayoutConnectionState.Connected;
const string pendingMessage =
"Tornado 장면 완료 이벤트를 기다리는 중이므로 연결을 해제하지 않았습니다.";
PublishStatus(pendingMessage);
return Result(
PlayoutOperation.Disconnect,
PlayoutResultCode.Unavailable,
pendingMessage);
}
}
_connectionState = PlayoutConnectionState.Disconnecting;
PublishStatus("Tornado 송출 연결을 해제하는 중입니다.");
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
@@ -749,9 +799,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, exception.Message));
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved))
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(resolved))
{
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다."));
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다."));
}
return PrepareActualAsync(resolved, cancellationToken);
@@ -810,9 +861,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다.");
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(_preparedCue))
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(_preparedCue))
{
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.");
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다.");
}
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.TakeIn, cancellationToken)
@@ -879,9 +931,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, exception.Message);
}
if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved))
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(resolved))
{
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.");
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "승인되지 않은 장면입니다.");
}
var availability = await EnsureCommandAvailableAsync(PlayoutOperation.Next, cancellationToken)
@@ -914,6 +967,108 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return result;
}
private async Task<PlayoutResult> UpdateOnAirCoreAsync(
PlayoutCue cue,
CancellationToken cancellationToken)
{
if (_onAirSceneName is null)
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Same-scene refresh requires a scene that is already on air.");
}
if (_options.Mode == PlayoutMode.Disabled)
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Playout is disabled.");
}
if (_options.Mode == PlayoutMode.DryRun)
{
if (!IsCurrentOnAirCue(cue))
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Same-scene refresh can update only the scene currently on air.");
}
_preparedCue = null;
var message = DryRunOperationMessage("Same-scene refresh was validated.");
PublishStatus(message);
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Success,
message);
}
if (!CanTakeIn())
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Live playout authorization is required.");
}
PlayoutCue resolved;
try
{
resolved = _options.ResolveCue(cue);
}
catch (PlayoutRequestException exception)
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
exception.Message);
}
if (_options.Mode is PlayoutMode.Test or PlayoutMode.Live &&
!_options.IsOutputSceneAllowed(resolved))
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"The scene is not approved for output.");
}
if (!IsCurrentOnAirCue(resolved))
{
return Result(
PlayoutOperation.UpdateOnAir,
PlayoutResultCode.Rejected,
"Same-scene refresh can update only the scene currently on air.");
}
var availability = await EnsureCommandAvailableAsync(
PlayoutOperation.UpdateOnAir,
cancellationToken).ConfigureAwait(false);
if (availability is not null)
{
return availability;
}
var result = await RunSessionOperationAsync(
PlayoutOperation.UpdateOnAir,
(session, dispatchLatch) => ExecuteSdkDispatch(
dispatchLatch,
() => session.UpdateOnAir(resolved, _options.LayoutIndex)),
partialOutcomeUnknown: true,
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_onAirSceneName = SafeSceneName(resolved);
_preparedCue = null;
PublishStatus("The on-air scene page was updated.");
}
return result;
}
private async Task<PlayoutResult> TakeOutCoreAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken)
@@ -960,7 +1115,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
_preparedCue = null;
_onAirSceneName = null;
PublishStatus("장면 송출이 종료되었습니다.");
PublishStatus(_session?.HasPendingLifecycleCallbacks == true
? "TAKE OUT 명령이 수락되어 Tornado 완료 이벤트를 기다리고 있습니다."
: "장면 송출이 종료되었습니다.");
}
return result;
@@ -1042,9 +1199,76 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Result(operation, PlayoutResultCode.Unavailable, "Tornado 송출 연결이 필요합니다.");
}
if (!await ProcessLifecycleCallbacksAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(operation);
}
if (RequiresCompletedPlay(operation) &&
_session?.HasPendingPlayCallbacks == true)
{
const string pendingPlayMessage =
"The previous SDK Play completion callback is still pending. " +
"Only TAKE OUT is allowed until Tornado confirms the result.";
PublishStatus(pendingPlayMessage);
return Result(
operation,
PlayoutResultCode.Unavailable,
pendingPlayMessage);
}
return null;
}
private static bool RequiresCompletedPlay(PlayoutOperation operation) => operation is
PlayoutOperation.Prepare or
PlayoutOperation.TakeIn or
PlayoutOperation.Next or
PlayoutOperation.UpdateOnAir;
private async Task<bool> ProcessLifecycleCallbacksAsync(
CancellationToken cancellationToken)
{
var session = _session;
if (session is null || !session.SupportsLifecycleCallbacks)
{
CaptureKtapEvidence(session);
return true;
}
if (_dispatcher is null || _dispatcher.IsQuarantined)
{
return false;
}
try
{
await _dispatcher.InvokeAsync(
() => session.ProcessPendingCallbacks(_options.LayoutIndex),
_options.OperationTimeout,
CancellationToken.None,
_ => AbandonSessionInline(session),
() => AbandonSessionInline(session)).ConfigureAwait(false);
CaptureKtapEvidence(session);
return true;
}
catch
{
_session = null;
_connectedProcessSnapshot = null;
if (_dispatcher.IsQuarantined)
{
_quarantineIncomplete = true;
}
else if (!await AbandonSessionOnStaAsync(session).ConfigureAwait(false))
{
_quarantineIncomplete = true;
}
return false;
}
}
private async Task<PlayoutResult> RunSessionOperationAsync(
PlayoutOperation operation,
Action<IK3dSession, SdkDispatchLatch> callback,
@@ -1177,6 +1401,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
}
var session = _session;
if (session?.HasPendingLifecycleCallbacks == true)
{
// A late OnScenePlayed/OnCutOut/OnStopAll callback still owns scene lifetime.
// Never issue Disconnect while that result is unresolved.
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return false;
}
_session = null;
_connectedProcessSnapshot = null;
if (session is null)
@@ -1599,7 +1831,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds,
LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read(
ref _lastKtapConnectState),
KtapHelloObserved = null
KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch
{
1 => false,
2 => true,
_ => null
}
};
}
@@ -1615,6 +1852,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
Volatile.Write(ref _lastKtapConnectState, (int)state);
}
if (session.SupportsLifecycleCallbacks)
{
Volatile.Write(
ref _ktapHelloObservedState,
session.KtapHelloObserved == true ? 2 : 1);
}
}
private void PreventLateKtapDispatchAndCapture(IK3dSession? session)
@@ -1660,6 +1904,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return value;
}
private bool IsCurrentOnAirCue(PlayoutCue cue)
{
var sceneName = cue.SceneName?.Trim();
return !string.IsNullOrEmpty(sceneName) &&
string.Equals(sceneName, _onAirSceneName, StringComparison.OrdinalIgnoreCase);
}
private string CurrentMessage() => _connectionState switch
{
PlayoutConnectionState.Connected =>
@@ -1704,6 +1955,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private static string SuccessMessage(PlayoutOperation operation) => operation switch
{
PlayoutOperation.UpdateOnAir => "The on-air scene page was updated.",
PlayoutOperation.Prepare => "장면이 준비되었습니다.",
PlayoutOperation.TakeIn => "장면이 송출되었습니다.",
PlayoutOperation.Next => "다음 장면이 송출되었습니다.",