branding credit: run in every scene and in recordings, not only while live
Creator 2026-09-26: "the made with llamacasty flash should appear in all scenes,
not just live" and "should also appear in recordings". The presenter was
Start()/Stop()-ed from UpdateLiveVisuals()'s IsLive branch, so a recording made
WITHOUT ever going live carried no credit at all -- the exact case the creator hit
while judging compositing from local recordings.
It is now Start()ed once in the MainViewModel ctor and never stopped on live-state
churn. One start covers every scene, the preview, the stream and the recording,
because go-live and local recording are the SAME FramePump: both
Streaming.Operations.cs:68 and :199 call StartAsync with the same brandFlash:
delegate. Verified by reading both call sites, not assumed -- there is no second
encoder path that needed a "redirect". The licence gate needs no live branch at
all: IsPremium's setter already pushes BrandFlashPresenter.Enabled from anywhere.
Fixed a trap that app-lifetime exposed: Enabled = false stops the presenter's
DispatcherTimer to cut the advertisement mid-credit. When Start() was per-go-live
the next go-live restarted it; with a single app-lifetime Start() nothing would,
so a key entered mid-session would leave the credit dead until the process was
restarted. The setter now restarts the timer when re-enabling while _running.
Tests (12 facts in BrandFlashOutputTests, +2):
Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt -- drives a real
never-live VM past the 5s first-flash delay and asks the frame the pump would
composite. Uses a new internal AdvanceBrandFlash seam; because Advance only
advances the cadence while the presenter is running, a credit coming out
proves Start() happened at construction.
ADowngradeMidSession_RestartsTheCadenceTimer -- asserts the premium/downgrade
edge decision directly (IsCadenceTimerEnabled) instead of sleeping through a
30-60s interval, which a synchronous test body cannot observe.
Full suite 364/364.
This commit is contained in:
@@ -381,6 +381,97 @@ public sealed class BrandFlashOutputTests
|
||||
Assert.True(prop.CanRead, "the preview needs to read the gate state");
|
||||
}
|
||||
|
||||
// ---------- the credit is NOT a go-live-only behaviour ----------
|
||||
|
||||
/// <summary>Reported 2026-09-26: "the made with llamacasty flash should appear in
|
||||
/// all scenes, not just live" and "should also appear in recordings". The presenter
|
||||
/// used to be Start/Stop-ed from <c>UpdateLiveVisuals</c>'s IsLive branch, so a
|
||||
/// recording made WITHOUT ever going live carried no credit at all.
|
||||
/// <para>This drives a real, freshly-constructed ViewModel that has never gone
|
||||
/// live, advances past the 5s first-flash delay and asks the frame the frame pump
|
||||
/// would composite. A non-null frame here is the whole fix: the cadence is running
|
||||
/// for the life of the app, and since go-live and local recording are the SAME
|
||||
/// FramePump (both call <c>StartAsync</c> with the same <c>brandFlash:</c> delegate)
|
||||
/// the recording carries the credit too.</para></summary>
|
||||
[Fact]
|
||||
public void Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = tempDb;
|
||||
var window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
// The precondition that used to guarantee an empty credit: offline,
|
||||
// not recording, never broadcast.
|
||||
Assert.False(vm.IsLive, "this test is about the NOT-live case");
|
||||
Assert.False(vm.IsRecording);
|
||||
Assert.Equal(StreamStatus.Offline, vm.StreamStatus);
|
||||
|
||||
// No credit is due yet — the first one lands 5s in.
|
||||
Assert.Null(vm.BrandFlashFrame());
|
||||
|
||||
// Step it in 1/30s increments like the real DispatcherTimer. Do NOT
|
||||
// pass 5.1 in one call: Advance both opens the presentation AND ages
|
||||
// it by the same delta, so a single 5.1s step would blow clean through
|
||||
// the 2s window and the credit would be gone before it was ever read.
|
||||
Step(vm, 5.1);
|
||||
|
||||
var frame = vm.BrandFlashFrame();
|
||||
Assert.True(frame != null,
|
||||
"no credit with no live session: the cadence is still gated on "
|
||||
+ "IsLive, so recordings made without going live carry no branding");
|
||||
Assert.Equal(1920, frame!.Width);
|
||||
Assert.Equal(1080, frame.Height);
|
||||
|
||||
// And it reaches the preview pane in this state too, not just the encoder.
|
||||
vm.PublishBrandFlashPreview(frame);
|
||||
window.UpdateLayout();
|
||||
var pane = (UserControl)window.FindName("PreviewPane")!;
|
||||
var holder = Find(pane, e => e is FrameworkElement f
|
||||
&& f.Name == "BrandFlashElement") as FrameworkElement;
|
||||
Assert.Equal(Visibility.Visible, holder!.Visibility);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>The presenter is started ONCE for the life of the app now, so the
|
||||
/// premium edge stopping its timer is no longer rescued by the next go-live
|
||||
/// calling <c>Start()</c> again: a key entered mid-session used to leave the credit
|
||||
/// dead until the process restarted. Re-enabling must resume the cadence.</summary>
|
||||
[Fact]
|
||||
public void ADowngradeMidSession_RestartsTheCadenceTimer()
|
||||
{
|
||||
_app.Run(() =>
|
||||
{
|
||||
using var presenter = new BrandFlashPresenter(new Random(11));
|
||||
presenter.Start();
|
||||
Assert.True(presenter.IsCadenceTimerEnabled,
|
||||
"Start() must leave the cadence timer running");
|
||||
|
||||
presenter.Enabled = false; // premium -> the advertisement is cut and the timer stops
|
||||
Assert.False(presenter.IsCadenceTimerEnabled);
|
||||
|
||||
presenter.Enabled = true; // key entered mid-session: no second Start() will come
|
||||
Assert.True(presenter.IsCadenceTimerEnabled,
|
||||
"the credit stayed dead after a downgrade — the premium edge stopped the "
|
||||
+ "timer and nothing restarted it, because Start() is now a one-time "
|
||||
+ "app-startup call rather than a per-go-live one");
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private static DependencyObject? Find(DependencyObject parent, Func<DependencyObject, bool> predicate)
|
||||
@@ -401,6 +492,12 @@ public sealed class BrandFlashOutputTests
|
||||
for (var t = 0.0; t < seconds; t += 1.0 / 30.0) presenter.Advance(1.0 / 30.0);
|
||||
}
|
||||
|
||||
/// <summary>Same stepping, against the ViewModel's own presenter.</summary>
|
||||
private static void Step(MainViewModel vm, double seconds)
|
||||
{
|
||||
for (var t = 0.0; t < seconds; t += 1.0 / 30.0) vm.AdvanceBrandFlash(1.0 / 30.0);
|
||||
}
|
||||
|
||||
/// <summary>Alpha bounding box of everything the frame actually drew.</summary>
|
||||
private static (int MinX, int MinY, int MaxX, int MaxY) AlphaBounds(VideoFrame frame)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user