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