diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs index 5efa714..18b7905 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs @@ -413,9 +413,16 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession try { - nextScene = new SceneReference( - InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName), - cue.SceneName); + // MainForm refreshes the selected DTO immediately before TAKE IN and + // Page NEXT can target the alias already on air. Tornado2 rejects a + // second LoadScene for an alias it still owns, so mutate and re-Prepare + // the retained loaded scene instead of creating a duplicate alias. + nextScene = + ReusableScene(_preparedScene, cue.SceneName) ?? + ReusableScene(_currentScene, cue.SceneName) ?? + new SceneReference( + InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName), + cue.SceneName); if (outputChannel.HasValue) { Invoke(nextScene.Value, "SetOutputChannelIndex", outputChannel.GetValueOrDefault()); @@ -548,10 +555,16 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession 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); + + foreach (var mutation in cue.Mutations ?? []) + { + if (IsBeforeTransactionSceneSetupMutation(mutation)) + { + ApplySceneSetupMutation(playingScene, mutation); + } + } + Invoke(engine, "BeginTransaction"); transactionStarted = true; @@ -562,11 +575,12 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession 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)) + // Match Show_PlayList(idx: 1): common background setup runs before the + // transaction, while builder-owned setup and object changes keep their + // explicit in-transaction ordering. + if (!IsBeforeTransactionSceneSetupMutation(mutation)) { - ApplyMutation(playingScene, mutation); + ApplyTransactionMutation(playingScene, mutation); } } @@ -1080,6 +1094,14 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession } } + private static SceneReference? ReusableScene( + SceneReference? scene, + string sceneName) => + scene is not null && + string.Equals(scene.Name, sceneName, StringComparison.OrdinalIgnoreCase) + ? scene + : null; + private void MoveAllScenesToPendingTakeOut() { AddDistinctScene(_pendingTakeOutScenes, _preparedScene); diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs index 2454063..acb26b5 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs @@ -458,7 +458,7 @@ public sealed class DynamicK3dSessionTests } [Fact] - public async Task UpdateOnAir_UsesPlayingSceneWithoutReloadingOrReapplyingSceneSetup() + public async Task UpdateOnAir_UsesPlayingSceneAndAppliesOnlyBeforeTransactionBackgroundSetup() { using var scenes = TemporarySceneDirectory.Create( "test-scene.t2s", @@ -475,6 +475,11 @@ public sealed class DynamicK3dSessionTests new PlayoutUseBackground(false), new PlayoutSetBackgroundTexture("Video/background.vrv"), new PlayoutSetBackgroundVideo("Video/background.vrv", 4, true), + new PlayoutSetBackgroundVideo( + "Video/background.vrv", + 8, + true, + PlayoutMutationTiming.InTransaction), new PlayoutSetVisible("badge", false) ])); var log = new FakeComLog(); @@ -509,12 +514,15 @@ public sealed class DynamicK3dSessionTests Assert.Equal( new[] { - "Play:10", "GetPlayingScene:10", + "UseBackground:0", + "SetBackgroundTexture:background.vrv", + "SetBackgroundVideo:background.vrv:4:1", "BeginTransaction", "GetObject:headline", "SetValue:headline:page two", "SetVisible:headline:1", + "SetBackgroundVideo:background.vrv:8:1", "GetObject:badge", "SetVisible:badge:0", "QueryVariables", @@ -525,10 +533,7 @@ public sealed class DynamicK3dSessionTests 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)); + call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal)); } [Fact] @@ -577,10 +582,6 @@ public sealed class DynamicK3dSessionTests 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); @@ -590,11 +591,48 @@ public sealed class DynamicK3dSessionTests TimeSpan.FromSeconds(5), CancellationToken.None); - Assert.Equal(3, log.Names.Count(name => name == "Play:10")); + Assert.Equal(2, 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 Prepare_SamePreparedScene_ReusesSceneWithoutSecondLoad() + { + 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 releaser = new FakeReleaser( + log, + (scene, "5001"), + (player, "Player"), + (engine, "Engine"), + (activator.EventHandler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 2); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator, releaser); + session.Connect(options); + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001")); + Assert.Equal(2, log.Names.Count(name => name == "Prepare:10")); + Assert.DoesNotContain("Unload:5001", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Release:5001")); + } + [Fact] public async Task LifecycleCallbacks_UnloadOnlyRetiredScenesAfterMatchingSuccess() { @@ -682,25 +720,19 @@ public sealed class DynamicK3dSessionTests } [Fact] - public async Task LifecycleCallbacks_SameNameReplacement_UnloadsOnlyAfterStopAll() + public async Task LifecycleCallbacks_CurrentSameNamePrepare_ReusesActiveSceneUntilStopAll() { using var scenes = TemporarySceneDirectory.Create("5001.t2s"); var options = SceneOptions(scenes.Path, "5001"); var log = new FakeComLog(); var player = new FakePlayer(log); - var retiredScene = new FakeScene(log, "5001"); var activeScene = new FakeScene(log, "5001"); - var scenesToLoad = new Queue([retiredScene, activeScene]); - var engine = new FakeEngine(log, player, retiredScene) - { - SceneResolver = _ => scenesToLoad.Dequeue() - }; + var engine = new FakeEngine(log, player, activeScene); var activator = new FakeActivator(log, engine); var callbacks = new RecordingEventHandlerFactory(); var releaser = new FakeReleaser( log, - (retiredScene, "5001-retired"), - (activeScene, "5001-active"), + (activeScene, "5001"), (player, "Player"), (engine, "Engine"), (callbacks.Handler, "EventHandler")); @@ -730,8 +762,8 @@ public sealed class DynamicK3dSessionTests 0, session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); Assert.DoesNotContain("Unload:5001", log.Names); - Assert.Contains("Release:5001-retired", log.Names); - Assert.DoesNotContain("Release:5001-active", log.Names); + Assert.DoesNotContain("Release:5001", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001")); session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); callbacks.Fire("OnStopAll", 1); @@ -739,39 +771,32 @@ public sealed class DynamicK3dSessionTests 1, session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); - Assert.Contains("Release:5001-active", log.Names); + Assert.Contains("Release:5001", log.Names); session.Disconnect(); return true; }, TimeSpan.FromSeconds(5), CancellationToken.None); - Assert.Empty(scenesToLoad); + Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001")); Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); - Assert.Equal(1, log.Names.Count(name => name == "Release:5001-retired")); - Assert.Equal(1, log.Names.Count(name => name == "Release:5001-active")); + Assert.Equal(1, log.Names.Count(name => name == "Release:5001")); } [Fact] - public async Task LifecycleCallbacks_SameNameReplacement_IgnoresSceneNameCasing() + public async Task Prepare_CurrentSceneReuse_IgnoresSceneNameCasing() { using var scenes = TemporarySceneDirectory.Create("SceneA.t2s"); var options = SceneOptions(scenes.Path, "SceneA"); var log = new FakeComLog(); var player = new FakePlayer(log); - var retiredScene = new FakeScene(log, "SceneA"); - var activeScene = new FakeScene(log, "scenea"); - var scenesToLoad = new Queue([retiredScene, activeScene]); - var engine = new FakeEngine(log, player, retiredScene) - { - SceneResolver = _ => scenesToLoad.Dequeue() - }; + var activeScene = new FakeScene(log, "SceneA"); + var engine = new FakeEngine(log, player, activeScene); var activator = new FakeActivator(log, engine); var callbacks = new RecordingEventHandlerFactory(); var releaser = new FakeReleaser( log, - (retiredScene, "SceneA-retired"), - (activeScene, "scenea-active"), + (activeScene, "SceneA"), (player, "Player"), (engine, "Engine"), (callbacks.Handler, "EventHandler")); @@ -799,7 +824,9 @@ public sealed class DynamicK3dSessionTests 0, session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); Assert.DoesNotContain("Unload:SceneA", log.Names); - Assert.Contains("Release:SceneA-retired", log.Names); + Assert.DoesNotContain("Release:SceneA", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "LoadScene:SceneA")); + Assert.DoesNotContain("LoadScene:scenea", log.Names); session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); callbacks.Fire("OnStopAll", 1); @@ -812,11 +839,10 @@ public sealed class DynamicK3dSessionTests TimeSpan.FromSeconds(5), CancellationToken.None); - Assert.Empty(scenesToLoad); - Assert.DoesNotContain("Unload:SceneA", log.Names); - Assert.Equal(1, log.Names.Count(name => name == "Unload:scenea")); - Assert.Equal(1, log.Names.Count(name => name == "Release:SceneA-retired")); - Assert.Equal(1, log.Names.Count(name => name == "Release:scenea-active")); + Assert.Equal(1, log.Names.Count(name => name == "LoadScene:SceneA")); + Assert.DoesNotContain("LoadScene:scenea", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Unload:SceneA")); + Assert.Equal(1, log.Names.Count(name => name == "Release:SceneA")); } [Fact]