fix: refresh the retained on-air scene

This commit is contained in:
2026-07-11 13:58:23 +09:00
parent c71ea425e8
commit bb4a3ec3e0
2 changed files with 300 additions and 32 deletions

View File

@@ -550,18 +550,33 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
var engine = _engine!;
var player = _player!;
var outputChannel = _outputChannel;
object? playingScene = null;
// Tornado2 can return an incomplete GetPlayingScene proxy. The scene loaded,
// prepared, and promoted by Play is the only trusted reference for an in-place
// refresh, and it remains retained until the normal take-out/unload lifecycle.
if (_preparedScene is not null)
{
throw new InvalidOperationException(
"An on-air update cannot run while another scene is prepared.");
}
var currentScene = ReusableScene(_currentScene, cue.SceneName)
?? throw new InvalidOperationException(
"No matching retained on-air scene is available.");
if (SupportsLifecycleCallbacks && _pendingPlayCallbacks != 0)
{
throw new InvalidOperationException(
"An on-air update cannot run while scene playback is pending.");
}
var transactionStarted = false;
try
{
playingScene = InvokeRequired(player, "GetPlayingScene", layoutIndex);
foreach (var mutation in cue.Mutations ?? [])
{
if (IsBeforeTransactionSceneSetupMutation(mutation))
{
ApplySceneSetupMutation(playingScene, mutation);
ApplySceneSetupMutation(currentScene.Value, mutation);
}
}
@@ -570,7 +585,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
foreach (var field in cue.Fields ?? [])
{
ApplyField(playingScene, field);
ApplyField(currentScene.Value, field);
}
foreach (var mutation in cue.Mutations ?? [])
@@ -580,11 +595,11 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
// explicit in-transaction ordering.
if (!IsBeforeTransactionSceneSetupMutation(mutation))
{
ApplyTransactionMutation(playingScene, mutation);
ApplyTransactionMutation(currentScene.Value, mutation);
}
}
Invoke(playingScene, "QueryVariables");
Invoke(currentScene.Value, "QueryVariables");
if (outputChannel.HasValue)
{
Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault());
@@ -595,20 +610,8 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
}
transactionStarted = false;
Invoke(player, "Prepare", layoutIndex, playingScene);
Invoke(player, "Prepare", layoutIndex, currentScene.Value);
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 = new SceneReference(playingScene, cue.SceneName);
playingScene = null;
}
else if (ReferenceEquals(_currentScene.Value, playingScene))
{
playingScene = null;
}
}
catch
{
@@ -626,10 +629,6 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
throw;
}
finally
{
ReleaseComObject(playingScene);
}
}
public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope)

View File

@@ -458,7 +458,7 @@ public sealed class DynamicK3dSessionTests
}
[Fact]
public async Task UpdateOnAir_UsesPlayingSceneAndAppliesOnlyBeforeTransactionBackgroundSetup()
public async Task UpdateOnAir_ReusesRetainedSceneAndIgnoresInvalidPlayingSceneProxy()
{
using var scenes = TemporarySceneDirectory.Create(
"test-scene.t2s",
@@ -484,7 +484,7 @@ public sealed class DynamicK3dSessionTests
]));
var log = new FakeComLog();
var scene = new FakeScene(log, "test-scene");
var player = new FakePlayer(log) { PlayingScene = scene };
var player = new FakePlayer(log) { PlayingScene = new object() };
var activator = new FakeActivator(log, new FakeEngine(log, player, scene));
await using var dispatcher = new StaDispatcher(capacity: 2);
var updateStart = 0;
@@ -514,7 +514,6 @@ public sealed class DynamicK3dSessionTests
Assert.Equal(
new[]
{
"GetPlayingScene:10",
"UseBackground:0",
"SetBackgroundTexture:background.vrv",
"SetBackgroundVideo:background.vrv:4:1",
@@ -531,20 +530,274 @@ public sealed class DynamicK3dSessionTests
"Play:10"
},
updateCalls);
Assert.Equal(1, updateCalls.Count(call => call == "Play:10"));
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
Assert.DoesNotContain(updateCalls, call =>
call.StartsWith("LoadScene:", StringComparison.Ordinal) ||
call.StartsWith("SetSceneEffectType:", StringComparison.Ordinal));
}
[Fact]
public async Task UpdateOnAir_CallbackSession_TracksItsPlayUntilMatchingCallback()
public async Task UpdateOnAir_MismatchedRetainedSceneFailsBeforeAnySdkMutation()
{
using var scenes = TemporarySceneDirectory.Create(
"5001.t2s",
"5006.t2s",
"Video\\background.vrv");
var options = SceneOptions(scenes.Path, "5001", "5006");
var initialCue = SceneCue(options, "5001");
var mismatchedCue = options.ResolveCue(new PlayoutCue(
"5006.t2s",
"5006",
[new PlayoutField("headline", "must not be sent", true)],
Mutations:
[
new PlayoutUseBackground(false),
new PlayoutSetBackgroundTexture("Video/background.vrv")
]));
var log = new FakeComLog();
var retainedScene = new FakeScene(log, "5001");
var player = new FakePlayer(log) { PlayingScene = new object() };
var activator = new FakeActivator(
log,
new FakeEngine(log, player, retainedScene));
await using var dispatcher = new StaDispatcher(capacity: 2);
string[] updateCalls = [];
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
session.Prepare(initialCue, options.LayoutIndex);
session.Play(options.LayoutIndex);
var updateStart = log.Names.Length;
Assert.Throws<InvalidOperationException>(
() => session.UpdateOnAir(mismatchedCue, options.LayoutIndex));
updateCalls = log.Names.Skip(updateStart).ToArray();
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Empty(updateCalls);
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
}
[Fact]
public async Task UpdateOnAir_WithoutRetainedCurrentSceneFailsBeforeAnySdkMutation()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var cue = SceneCue(options, "5001");
var log = new FakeComLog();
var player = new FakePlayer(log) { PlayingScene = new object() };
var scene = new FakeScene(log, "5001");
var activator = new FakeActivator(log, new FakeEngine(log, player, scene));
await using var dispatcher = new StaDispatcher(capacity: 2);
string[] updateCalls = [];
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
var updateStart = log.Names.Length;
Assert.Throws<InvalidOperationException>(
() => session.UpdateOnAir(cue, options.LayoutIndex));
updateCalls = log.Names.Skip(updateStart).ToArray();
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Empty(updateCalls);
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
}
[Fact]
public async Task UpdateOnAir_WithAnotherPreparedSceneFailsBeforeAnySdkMutation()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var options = SceneOptions(scenes.Path, "5001", "5006");
var cue5001 = SceneCue(options, "5001");
var log = new FakeComLog();
var player = new FakePlayer(log) { PlayingScene = new object() };
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);
await using var dispatcher = new StaDispatcher(capacity: 2);
string[] updateCalls = [];
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
session.Prepare(cue5001, options.LayoutIndex);
session.Play(options.LayoutIndex);
session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
var updateStart = log.Names.Length;
Assert.Throws<InvalidOperationException>(
() => session.UpdateOnAir(cue5001, options.LayoutIndex));
updateCalls = log.Names.Skip(updateStart).ToArray();
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Empty(updateCalls);
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
}
[Fact]
public async Task UpdateOnAir_WithPendingInitialPlayFailsBeforeAnySdkMutation()
{
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 player = new FakePlayer(log) { PlayingScene = new object() };
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);
string[] updateCalls = [];
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);
Assert.True(session.HasPendingPlayCallbacks);
var updateStart = log.Names.Length;
Assert.Throws<InvalidOperationException>(
() => session.UpdateOnAir(cue, options.LayoutIndex));
updateCalls = log.Names.Skip(updateStart).ToArray();
callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.False(session.HasPendingPlayCallbacks);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Empty(updateCalls);
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
}
[Fact]
public async Task UpdateOnAir_TransactionFailureRollsBackAndRetainsSceneForStopAllCleanup()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var options = SceneOptions(scenes.Path, "5001");
var initialCue = SceneCue(options, "5001");
var updateCue = options.ResolveCue(new PlayoutCue(
"5001.t2s",
"5001",
[new PlayoutField("headline", "refresh", true)],
Mutations: [new PlayoutUseBackground(false)]));
var log = new FakeComLog();
var scene = new FakeScene(log, "5001");
var player = new FakePlayer(log) { PlayingScene = new object() };
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(initialCue, options.LayoutIndex);
session.Play(options.LayoutIndex);
callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex);
session.ProcessPendingCallbacks(options.LayoutIndex);
scene.QueryVariablesFailure = new InvalidOperationException("fake refresh failure");
var updateStart = log.Names.Length;
Assert.Throws<InvalidOperationException>(
() => session.UpdateOnAir(updateCue, options.LayoutIndex));
var updateCalls = log.Names.Skip(updateStart).ToArray();
Assert.Equal(
new[]
{
"UseBackground:0",
"BeginTransaction",
"GetObject:headline",
"SetValue:headline:refresh",
"SetVisible:headline:1",
"QueryVariables",
"RollbackTransaction"
},
updateCalls);
Assert.DoesNotContain("Release:5001", log.Names);
Assert.DoesNotContain("Unload:5001", log.Names);
scene.QueryVariablesFailure = null;
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
callbacks.Fire("OnStopAll", 1);
Assert.Equal(
1,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
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 == "Release:5001"));
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
}
[Fact]
public async Task UpdateOnAir_CallbackSession_TracksPlayAndRetainsSceneUntilStopAll()
{
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 = new object() };
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var callbacks = new RecordingEventHandlerFactory();
@@ -577,6 +830,8 @@ public sealed class DynamicK3dSessionTests
session.UpdateOnAir(cue, options.LayoutIndex);
Assert.True(session.HasPendingLifecycleCallbacks);
Assert.DoesNotContain("Release:5001", log.Names);
Assert.DoesNotContain("Unload:5001", log.Names);
callbacks.Fire("OnScenePlayed", 1, 8, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
@@ -585,6 +840,13 @@ public sealed class DynamicK3dSessionTests
callbacks.Fire("OnScenePlayed", 1, 9, 10);
session.ProcessPendingCallbacks(options.LayoutIndex);
Assert.False(session.HasPendingLifecycleCallbacks);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
callbacks.Fire("OnStopAll", 1);
Assert.Equal(
1,
session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount);
Assert.False(session.HasPendingLifecycleCallbacks);
session.Disconnect();
return true;
},
@@ -592,8 +854,9 @@ public sealed class DynamicK3dSessionTests
CancellationToken.None);
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);
Assert.DoesNotContain("GetPlayingScene:10", log.Names);
Assert.Equal(1, log.Names.Count(name => name == "Unload:5001"));
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
}
[Fact]
@@ -1729,7 +1992,7 @@ public sealed class DynamicK3dSessionTests
{
private readonly FakeComLog _log;
private readonly string? _name;
private readonly Exception? _queryVariablesFailure;
private Exception? _queryVariablesFailure;
private readonly Exception? _unloadFailure;
public FakeScene(
@@ -1773,6 +2036,12 @@ public sealed class DynamicK3dSessionTests
}
}
public Exception? QueryVariablesFailure
{
get => _queryVariablesFailure;
set => _queryVariablesFailure = value;
}
public void Unload()
{
_log.Add(_name is null ? "Unload" : $"Unload:{_name}");