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,6 +1,7 @@
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
@@ -331,8 +332,8 @@ public sealed class DynamicK3dSessionTests
"SetVisible:headline:1",
"GetObject:badge",
"SetVisible:badge:0",
"EndTransactionOnChannel:9",
"QueryVariables",
"EndTransactionOnChannel:9",
"Prepare:10",
"Play:10",
expectedTakeOutCall,
@@ -343,6 +344,495 @@ public sealed class DynamicK3dSessionTests
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
}
[Fact]
public async Task Prepare_AppliesEveryLegacyMutationInOriginalTransactionOrder()
{
using var scenes = TemporarySceneDirectory.Create(
"test-scene.t2s",
"Images\\badge.png",
"Video\\background.vrv");
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var cue = options.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
FadeDuration: 4,
Mutations:
[
new PlayoutUseBackground(true),
new PlayoutSetBackgroundTexture("Video/background.vrv"),
new PlayoutSetBackgroundVideo("Video/background.vrv", 2, true),
new PlayoutSetValue("headline", "value"),
new PlayoutSetBackgroundVideo(
"Video/background.vrv",
3,
true,
PlayoutMutationTiming.InTransaction),
new PlayoutSetAssetValue("badge", "Images/badge.png"),
new PlayoutSetVisible("badge", false),
new PlayoutSetFaceColor("headline", 1, 2, 3, 4),
new PlayoutSetPosition("position", 1, 2, 3, PlayoutVectorComponents.XYZ),
new PlayoutSetPositionKey("position", 5, 6, 7, 8, PlayoutVectorComponents.Y),
new PlayoutSetScale("scale", 9, 10, 11, PlayoutVectorComponents.X),
new PlayoutSetCropKey("crop", 1, 12, 13, 14, 15, PlayoutCropEdges.Vertical),
new PlayoutSetCircleAngleKey("circle", 2, 16, 17, PlayoutAngleComponents.All),
new PlayoutSetPathPoints(
"path",
[new PlayoutPoint(18, 19, 20), new PlayoutPoint(21, 22, 23)]),
new PlayoutSetPathShapePoints(
"shape",
[new PlayoutPoint(24, 25, 26)])
]));
var log = new FakeComLog();
var scene = new FakeScene(log);
var activator = new FakeActivator(
log,
new FakeEngine(log, new FakePlayer(log), scene));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
session.Prepare(cue, options.LayoutIndex);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(
new[]
{
"LoadScene:test-scene",
"SetOutputChannelIndex:9",
"SetSceneEffectType:1:7:4",
"UseBackground:1",
"SetBackgroundTexture:background.vrv",
"SetBackgroundVideo:background.vrv:2:1",
"BeginTransaction",
"GetObject:headline",
"SetValue:headline:value",
"SetBackgroundVideo:background.vrv:3:1",
"GetObject:badge",
"SetValue:badge:badge.png",
"GetObject:badge",
"SetVisible:badge:0",
"GetObject:headline",
"SetFaceColor:headline:1:2:3:4",
"GetObject:position",
"SetPosition:position:1:2:3:7",
"GetObject:position",
"SetPositionKey:position:5:6:7:8:2",
"GetObject:scale",
"SetScale:scale:9:10:11:1",
"GetObject:crop",
"SetCropKey:crop:1:12:13:14:15:10",
"GetObject:circle",
"SetCircleAngleKey:circle:2:16:17:3",
"GetObject:path",
"BeginPathPoint:path",
"ClearPathPoints:path",
"AddPathPoint:path:18:19:20",
"AddPathPoint:path:21:22:23",
"EndPathPoint:path",
"GetObject:shape",
"BeginPathShapePoint:shape",
"ClearPathShapePoints:shape",
"AddPathShapePoint:shape:24:25:26",
"EndPathShapePoint:shape",
"QueryVariables",
"EndTransactionOnChannel:9",
"Prepare:10"
},
log.Names.Where(name =>
name.StartsWith("LoadScene:", StringComparison.Ordinal) ||
name.StartsWith("Set", StringComparison.Ordinal) ||
name.StartsWith("Use", StringComparison.Ordinal) ||
name.StartsWith("Begin", StringComparison.Ordinal) ||
name.StartsWith("Clear", StringComparison.Ordinal) ||
name.StartsWith("Add", StringComparison.Ordinal) ||
name.StartsWith("End", StringComparison.Ordinal) ||
name.StartsWith("GetObject:", StringComparison.Ordinal) ||
name == "QueryVariables" || name == "Prepare:10"));
}
[Fact]
public async Task UpdateOnAir_UsesPlayingSceneWithoutReloadingOrReapplyingSceneSetup()
{
using var scenes = TemporarySceneDirectory.Create(
"test-scene.t2s",
"Video\\background.vrv");
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var initialCue = SceneCue(options, "test-scene");
var updateCue = options.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
[new PlayoutField("headline", "page two", true)],
FadeDuration: 59,
Mutations:
[
new PlayoutUseBackground(false),
new PlayoutSetBackgroundTexture("Video/background.vrv"),
new PlayoutSetBackgroundVideo("Video/background.vrv", 4, true),
new PlayoutSetVisible("badge", false)
]));
var log = new FakeComLog();
var scene = new FakeScene(log, "test-scene");
var player = new FakePlayer(log) { PlayingScene = scene };
var activator = new FakeActivator(log, new FakeEngine(log, player, scene));
await using var dispatcher = new StaDispatcher(capacity: 2);
var updateStart = 0;
var updateEnd = 0;
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
session.Prepare(initialCue, options.LayoutIndex);
session.Play(options.LayoutIndex);
updateStart = log.Names.Length;
session.UpdateOnAir(updateCue, options.LayoutIndex);
updateEnd = log.Names.Length;
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
var updateCalls = log.Names
.Skip(updateStart)
.Take(updateEnd - updateStart)
.ToArray();
Assert.Equal(
new[]
{
"Play:10",
"GetPlayingScene:10",
"BeginTransaction",
"GetObject:headline",
"SetValue:headline:page two",
"SetVisible:headline:1",
"GetObject:badge",
"SetVisible:badge:0",
"QueryVariables",
"EndTransactionOnChannel:9",
"Prepare:10",
"Play:10"
},
updateCalls);
Assert.DoesNotContain(updateCalls, call =>
call.StartsWith("LoadScene:", StringComparison.Ordinal) ||
call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal) ||
call.StartsWith("UseBackground:", StringComparison.Ordinal) ||
call.StartsWith("SetBackgroundTexture:", StringComparison.Ordinal) ||
call.StartsWith("SetBackgroundVideo:", StringComparison.Ordinal));
}
[Fact]
public async Task UpdateOnAir_CallbackSession_TracksItsPlayUntilMatchingCallback()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var cue = SceneCue(options, "5001");
var log = new FakeComLog();
var scene = new FakeScene(log, "5001");
var player = new FakePlayer(log) { PlayingScene = scene };
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene, "5001"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
session.Prepare(cue, options.LayoutIndex);
session.Play(options.LayoutIndex);
callbacks.Fire("OnScenePlayed", 1, 8, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.False(session.HasPendingLifecycleCallbacks);
session.UpdateOnAir(cue, options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 8, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.False(session.HasPendingLifecycleCallbacks);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(3, log.Names.Count(name => name == "Play:10"));
Assert.Equal(1, log.Names.Count(name => name == "GetPlayingScene:10"));
Assert.DoesNotContain("Unload:5001", log.Names);
}
[Fact]
public async Task LifecycleCallbacks_UnloadOnlyRetiredScenesAfterMatchingSuccess()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var options = SceneOptions(scenes.Path, "5001", "5006");
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene5001 = new FakeScene(log, "5001");
var scene5006 = new FakeScene(log, "5006");
var engine = new FakeEngine(log, player, scene5001)
{
SceneResolver = name => name == "5006" ? scene5006 : scene5001
};
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene5001, "5001"),
(scene5006, "5006"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 3);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
Assert.True(session.SupportsLifecycleCallbacks);
Assert.False(session.KtapHelloObserved);
callbacks.Fire("OnHello");
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
var first = session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(first.HelloObserved);
Assert.Equal(0, first.UnloadedSceneCount);
session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
session.Play(options.LayoutIndex);
Assert.DoesNotContain("Unload:5001", log.Names);
callbacks.Fire("OnScenePlayed", 1, 8, 10);
Assert.Equal(
0,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.DoesNotContain("Unload:5001", log.Names);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
Assert.Equal(
1,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.Contains("Unload:5001", log.Names);
Assert.Contains("Release:5001", log.Names);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
Assert.Equal(
0,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.DoesNotContain("Unload:5006", log.Names);
callbacks.Fire("OnStopAll", 1);
Assert.Equal(
1,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.Contains("Unload:5006", log.Names);
Assert.Contains("Release:5006", log.Names);
Assert.False(session.HasPendingLifecycleCallbacks);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
Assert.Equal(1, log.Names.Count(name => name == "Unload:5006"));
}
[Fact]
public async Task StopAllSuccess_ClearsInterruptedPlayBeforeDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var log = new FakeComLog();
var scene = new FakeScene(log, "5001");
var player = new FakePlayer(log);
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene, "5001"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
Assert.True(session.HasPendingPlayCallbacks);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
callbacks.Fire("OnStopAll", 1);
var drained = session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.Equal(1, drained.UnloadedSceneCount);
Assert.False(session.HasPendingPlayCallbacks);
Assert.False(session.HasPendingLifecycleCallbacks);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Contains("StopAll", log.Names);
Assert.Contains("Unload:5001", log.Names);
Assert.Contains("Disconnect", log.Names);
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
}
[Fact]
public async Task CutOutSuccess_ClearsOnlyAfterMatchingOutputBeforeDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var log = new FakeComLog();
var scene = new FakeScene(log, "5001");
var player = new FakePlayer(log);
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene, "5001"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.Layout);
callbacks.Fire("OnCutOut", 1, 8, options.LayoutIndex);
Assert.Equal(
0,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.True(session.HasPendingPlayCallbacks);
Assert.True(session.HasPendingLifecycleCallbacks);
Assert.DoesNotContain("Unload:5001", log.Names);
callbacks.Fire("OnCutOut", 1, 9, options.LayoutIndex);
Assert.Equal(
1,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.False(session.HasPendingPlayCallbacks);
Assert.False(session.HasPendingLifecycleCallbacks);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Contains("CutOut:10", log.Names);
Assert.Contains("Unload:5001", log.Names);
Assert.Contains("Disconnect", log.Names);
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
}
[Fact]
public async Task LifecycleCallbackFailure_DoesNotUnloadAndFailsClosed()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene = new FakeScene(log, "5001");
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser(
log,
(scene, "5001"),
(player, "Player"),
(engine, "Engine"),
(callbacks.Handler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
activator,
releaser,
new InstalledK3dInteropMethodInvoker(),
callbacks);
session.Connect(options);
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
callbacks.Fire("OnStopAll", 0);
Assert.Throws<InvalidOperationException>(
() => session.ProcessPendingCallbacks(options.LayoutIndex));
Assert.DoesNotContain("Unload:5001", log.Names);
session.Abandon();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
}
[Fact]
public async Task Prepare_WithoutOutputChannel_UsesDefaultPlayerAndDoesNotSetSceneChannel()
{
@@ -385,8 +875,8 @@ public sealed class DynamicK3dSessionTests
"LoadScene:test-scene",
"SetSceneEffectType:1:7:12",
"BeginTransaction",
"EndTransaction",
"QueryVariables",
"EndTransaction",
"Prepare:10",
"Disconnect"
},
@@ -566,6 +1056,7 @@ public sealed class DynamicK3dSessionTests
"Prepare:10",
"Play:10",
"LoadScene:5006",
"RollbackTransaction",
"StopAll",
"Disconnect",
"Release:5006",
@@ -853,6 +1344,25 @@ public sealed class DynamicK3dSessionTests
public object Create(Guid classId) => throw _exception;
}
private sealed class RecordingEventHandlerFactory : IK3dEventHandlerFactory
{
private IK3dEventSink? _sink;
public bool SupportsCallbacks => true;
public object Handler { get; } = new();
public object Create(IK3dEventSink sink)
{
_sink = sink;
return Handler;
}
public void Fire(string methodName, params object?[] arguments) =>
(_sink ?? throw new InvalidOperationException("Callback sink is not connected."))
.OnCallback(methodName, arguments);
}
private sealed class BlockingEngineActivator : ILateBoundComActivator
{
private readonly FakeComLog _log;
@@ -1022,6 +1532,15 @@ public sealed class DynamicK3dSessionTests
_playFailure = playFailure;
}
public object? PlayingScene { get; set; }
public object GetPlayingScene(int layoutIndex)
{
_log.Add($"GetPlayingScene:{layoutIndex}");
return PlayingScene
?? throw new InvalidOperationException("No fake playing scene is configured.");
}
public void Prepare(int layoutIndex, object scene)
{
Assert.NotNull(scene);
@@ -1067,6 +1586,14 @@ public sealed class DynamicK3dSessionTests
public void SetSceneEffectType(int inEffect, int effectType, int duration) =>
_log.Add($"SetSceneEffectType:{inEffect}:{effectType}:{duration}");
public void UseBackground(int enabled) => _log.Add($"UseBackground:{enabled}");
public void SetBackgroundTexture(string path) =>
_log.Add($"SetBackgroundTexture:{System.IO.Path.GetFileName(path)}");
public void SetBackgroundVideo(string path, int loopCount, int loopInfinite) =>
_log.Add($"SetBackgroundVideo:{System.IO.Path.GetFileName(path)}:{loopCount}:{loopInfinite}");
public object GetObject(string objectName)
{
_log.Add($"GetObject:{objectName}");
@@ -1103,8 +1630,58 @@ public sealed class DynamicK3dSessionTests
_name = name;
}
public void SetValue(string value) => _log.Add($"SetValue:{_name}:{value}");
public void SetValue(string value) => _log.Add(
$"SetValue:{_name}:{(System.IO.Path.IsPathFullyQualified(value) ? System.IO.Path.GetFileName(value) : value)}");
public void SetVisible(int visible) => _log.Add($"SetVisible:{_name}:{visible}");
public void SetFaceColor(int red, int green, int blue, int alpha) =>
_log.Add($"SetFaceColor:{_name}:{red}:{green}:{blue}:{alpha}");
public void SetPosition(float x, float y, float z, int components) =>
_log.Add(FormattableString.Invariant($"SetPosition:{_name}:{x}:{y}:{z}:{components}"));
public void SetPositionKey(int keyIndex, float x, float y, float z, int components) =>
_log.Add(FormattableString.Invariant(
$"SetPositionKey:{_name}:{keyIndex}:{x}:{y}:{z}:{components}"));
public void SetScale(float x, float y, float z, int components) =>
_log.Add(FormattableString.Invariant($"SetScale:{_name}:{x}:{y}:{z}:{components}"));
public void SetCropKey(
int keyIndex,
float left,
float top,
float right,
float bottom,
int edges) =>
_log.Add(FormattableString.Invariant(
$"SetCropKey:{_name}:{keyIndex}:{left}:{top}:{right}:{bottom}:{edges}"));
public void SetCircleAngleKey(
int keyIndex,
float start,
float end,
int components) =>
_log.Add(FormattableString.Invariant(
$"SetCircleAngleKey:{_name}:{keyIndex}:{start}:{end}:{components}"));
public void BeginPathPoint() => _log.Add($"BeginPathPoint:{_name}");
public void ClearPathPoints() => _log.Add($"ClearPathPoints:{_name}");
public void AddPathPoint(float x, float y, float z) =>
_log.Add(FormattableString.Invariant($"AddPathPoint:{_name}:{x}:{y}:{z}"));
public void EndPathPoint() => _log.Add($"EndPathPoint:{_name}");
public void BeginPathShapePoint() => _log.Add($"BeginPathShapePoint:{_name}");
public void ClearPathShapePoints() => _log.Add($"ClearPathShapePoints:{_name}");
public void AddPathShapePoint(float x, float y, float z) =>
_log.Add(FormattableString.Invariant($"AddPathShapePoint:{_name}:{x}:{y}:{z}"));
public void EndPathShapePoint() => _log.Add($"EndPathShapePoint:{_name}");
}
}