fix: reuse scenes for on-air refresh

This commit is contained in:
2026-07-11 12:13:30 +09:00
parent 3e3292b8a9
commit d11abfa567
2 changed files with 100 additions and 52 deletions

View File

@@ -413,9 +413,16 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
try try
{ {
nextScene = new SceneReference( // MainForm refreshes the selected DTO immediately before TAKE IN and
InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName), // Page NEXT can target the alias already on air. Tornado2 rejects a
cue.SceneName); // 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) if (outputChannel.HasValue)
{ {
Invoke(nextScene.Value, "SetOutputChannelIndex", outputChannel.GetValueOrDefault()); Invoke(nextScene.Value, "SetOutputChannelIndex", outputChannel.GetValueOrDefault());
@@ -548,10 +555,16 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
try 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); playingScene = InvokeRequired(player, "GetPlayingScene", layoutIndex);
foreach (var mutation in cue.Mutations ?? [])
{
if (IsBeforeTransactionSceneSetupMutation(mutation))
{
ApplySceneSetupMutation(playingScene, mutation);
}
}
Invoke(engine, "BeginTransaction"); Invoke(engine, "BeginTransaction");
transactionStarted = true; transactionStarted = true;
@@ -562,11 +575,12 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
foreach (var mutation in cue.Mutations ?? []) foreach (var mutation in cue.Mutations ?? [])
{ {
// The legacy idx == 1 path updates the playing scene in place. It does not // Match Show_PlayList(idx: 1): common background setup runs before the
// reapply scene-level background setup or the scene transition effect. // transaction, while builder-owned setup and object changes keep their
if (!IsSceneLevelMutation(mutation)) // 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() private void MoveAllScenesToPendingTakeOut()
{ {
AddDistinctScene(_pendingTakeOutScenes, _preparedScene); AddDistinctScene(_pendingTakeOutScenes, _preparedScene);

View File

@@ -458,7 +458,7 @@ public sealed class DynamicK3dSessionTests
} }
[Fact] [Fact]
public async Task UpdateOnAir_UsesPlayingSceneWithoutReloadingOrReapplyingSceneSetup() public async Task UpdateOnAir_UsesPlayingSceneAndAppliesOnlyBeforeTransactionBackgroundSetup()
{ {
using var scenes = TemporarySceneDirectory.Create( using var scenes = TemporarySceneDirectory.Create(
"test-scene.t2s", "test-scene.t2s",
@@ -475,6 +475,11 @@ public sealed class DynamicK3dSessionTests
new PlayoutUseBackground(false), new PlayoutUseBackground(false),
new PlayoutSetBackgroundTexture("Video/background.vrv"), new PlayoutSetBackgroundTexture("Video/background.vrv"),
new PlayoutSetBackgroundVideo("Video/background.vrv", 4, true), new PlayoutSetBackgroundVideo("Video/background.vrv", 4, true),
new PlayoutSetBackgroundVideo(
"Video/background.vrv",
8,
true,
PlayoutMutationTiming.InTransaction),
new PlayoutSetVisible("badge", false) new PlayoutSetVisible("badge", false)
])); ]));
var log = new FakeComLog(); var log = new FakeComLog();
@@ -509,12 +514,15 @@ public sealed class DynamicK3dSessionTests
Assert.Equal( Assert.Equal(
new[] new[]
{ {
"Play:10",
"GetPlayingScene:10", "GetPlayingScene:10",
"UseBackground:0",
"SetBackgroundTexture:background.vrv",
"SetBackgroundVideo:background.vrv:4:1",
"BeginTransaction", "BeginTransaction",
"GetObject:headline", "GetObject:headline",
"SetValue:headline:page two", "SetValue:headline:page two",
"SetVisible:headline:1", "SetVisible:headline:1",
"SetBackgroundVideo:background.vrv:8:1",
"GetObject:badge", "GetObject:badge",
"SetVisible:badge:0", "SetVisible:badge:0",
"QueryVariables", "QueryVariables",
@@ -525,10 +533,7 @@ public sealed class DynamicK3dSessionTests
updateCalls); updateCalls);
Assert.DoesNotContain(updateCalls, call => Assert.DoesNotContain(updateCalls, call =>
call.StartsWith("LoadScene:", StringComparison.Ordinal) || call.StartsWith("LoadScene:", StringComparison.Ordinal) ||
call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal) || call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal));
call.StartsWith("UseBackground:", StringComparison.Ordinal) ||
call.StartsWith("SetBackgroundTexture:", StringComparison.Ordinal) ||
call.StartsWith("SetBackgroundVideo:", StringComparison.Ordinal));
} }
[Fact] [Fact]
@@ -577,10 +582,6 @@ public sealed class DynamicK3dSessionTests
session.ProcessPendingCallbacks(options.LayoutIndex); session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks); Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
callbacks.Fire("OnScenePlayed", 1, 9, 10); callbacks.Fire("OnScenePlayed", 1, 9, 10);
session.ProcessPendingCallbacks(options.LayoutIndex); session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.False(session.HasPendingLifecycleCallbacks); Assert.False(session.HasPendingLifecycleCallbacks);
@@ -590,11 +591,48 @@ public sealed class DynamicK3dSessionTests
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5),
CancellationToken.None); 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.Equal(1, log.Names.Count(name => name == "GetPlayingScene:10"));
Assert.DoesNotContain("Unload:5001", log.Names); 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] [Fact]
public async Task LifecycleCallbacks_UnloadOnlyRetiredScenesAfterMatchingSuccess() public async Task LifecycleCallbacks_UnloadOnlyRetiredScenesAfterMatchingSuccess()
{ {
@@ -682,25 +720,19 @@ public sealed class DynamicK3dSessionTests
} }
[Fact] [Fact]
public async Task LifecycleCallbacks_SameNameReplacement_UnloadsOnlyAfterStopAll() public async Task LifecycleCallbacks_CurrentSameNamePrepare_ReusesActiveSceneUntilStopAll()
{ {
using var scenes = TemporarySceneDirectory.Create("5001.t2s"); using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001"); var options = SceneOptions(scenes.Path, "5001");
var log = new FakeComLog(); var log = new FakeComLog();
var player = new FakePlayer(log); var player = new FakePlayer(log);
var retiredScene = new FakeScene(log, "5001");
var activeScene = new FakeScene(log, "5001"); var activeScene = new FakeScene(log, "5001");
var scenesToLoad = new Queue<FakeScene>([retiredScene, activeScene]); var engine = new FakeEngine(log, player, activeScene);
var engine = new FakeEngine(log, player, retiredScene)
{
SceneResolver = _ => scenesToLoad.Dequeue()
};
var activator = new FakeActivator(log, engine); var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory(); var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser( var releaser = new FakeReleaser(
log, log,
(retiredScene, "5001-retired"), (activeScene, "5001"),
(activeScene, "5001-active"),
(player, "Player"), (player, "Player"),
(engine, "Engine"), (engine, "Engine"),
(callbacks.Handler, "EventHandler")); (callbacks.Handler, "EventHandler"));
@@ -730,8 +762,8 @@ public sealed class DynamicK3dSessionTests
0, 0,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.DoesNotContain("Unload:5001", log.Names); Assert.DoesNotContain("Unload:5001", log.Names);
Assert.Contains("Release:5001-retired", log.Names); Assert.DoesNotContain("Release:5001", log.Names);
Assert.DoesNotContain("Release:5001-active", log.Names); Assert.Equal(1, log.Names.Count(name => name == "LoadScene:5001"));
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
callbacks.Fire("OnStopAll", 1); callbacks.Fire("OnStopAll", 1);
@@ -739,39 +771,32 @@ public sealed class DynamicK3dSessionTests
1, 1,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); 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(); session.Disconnect();
return true; return true;
}, },
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5),
CancellationToken.None); 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 == "Unload:5001"));
Assert.Equal(1, log.Names.Count(name => name == "Release:5001-retired")); Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
Assert.Equal(1, log.Names.Count(name => name == "Release:5001-active"));
} }
[Fact] [Fact]
public async Task LifecycleCallbacks_SameNameReplacement_IgnoresSceneNameCasing() public async Task Prepare_CurrentSceneReuse_IgnoresSceneNameCasing()
{ {
using var scenes = TemporarySceneDirectory.Create("SceneA.t2s"); using var scenes = TemporarySceneDirectory.Create("SceneA.t2s");
var options = SceneOptions(scenes.Path, "SceneA"); var options = SceneOptions(scenes.Path, "SceneA");
var log = new FakeComLog(); var log = new FakeComLog();
var player = new FakePlayer(log); var player = new FakePlayer(log);
var retiredScene = new FakeScene(log, "SceneA"); var activeScene = new FakeScene(log, "SceneA");
var activeScene = new FakeScene(log, "scenea"); var engine = new FakeEngine(log, player, activeScene);
var scenesToLoad = new Queue<FakeScene>([retiredScene, activeScene]);
var engine = new FakeEngine(log, player, retiredScene)
{
SceneResolver = _ => scenesToLoad.Dequeue()
};
var activator = new FakeActivator(log, engine); var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory(); var callbacks = new RecordingEventHandlerFactory();
var releaser = new FakeReleaser( var releaser = new FakeReleaser(
log, log,
(retiredScene, "SceneA-retired"), (activeScene, "SceneA"),
(activeScene, "scenea-active"),
(player, "Player"), (player, "Player"),
(engine, "Engine"), (engine, "Engine"),
(callbacks.Handler, "EventHandler")); (callbacks.Handler, "EventHandler"));
@@ -799,7 +824,9 @@ public sealed class DynamicK3dSessionTests
0, 0,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.DoesNotContain("Unload:SceneA", log.Names); 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); session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
callbacks.Fire("OnStopAll", 1); callbacks.Fire("OnStopAll", 1);
@@ -812,11 +839,10 @@ public sealed class DynamicK3dSessionTests
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5),
CancellationToken.None); CancellationToken.None);
Assert.Empty(scenesToLoad); Assert.Equal(1, log.Names.Count(name => name == "LoadScene:SceneA"));
Assert.DoesNotContain("Unload:SceneA", log.Names); Assert.DoesNotContain("LoadScene:scenea", log.Names);
Assert.Equal(1, log.Names.Count(name => name == "Unload:scenea")); 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"));
Assert.Equal(1, log.Names.Count(name => name == "Release:scenea-active"));
} }
[Fact] [Fact]