From 9761b1d4be39fdd6bb412c505925233e07e916a0 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 27 Sep 2026 09:33:30 -0700 Subject: [PATCH] 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. --- HANDOFF.md | 35 +++++-- Services/Compositor/BrandFlashPresenter.cs | 20 ++++ TASKS.md | 10 +- ViewModels/MainViewModel.BrandFlash.cs | 8 ++ .../MainViewModel.Streaming.Operations.cs | 21 ++-- ViewModels/MainViewModel.cs | 9 ++ ai.md | 18 ++++ ytLive.Tests/BrandFlashOutputTests.cs | 97 +++++++++++++++++++ 8 files changed, 193 insertions(+), 25 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 63b1521..59b916d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -13,6 +13,24 @@ compositing from **local recordings**. They assumed the live path needed a separ (go-live) and `:199` (record) call that same `StartAsync`. Record+simulcast is one ffmpeg with two outputs. Five items, tracked in `TASKS.md` → "Creator-reported batch". +### Done: the branding flash is no longer a go-live-only behaviour + +The presenter was `Start()`/`Stop()`-ed from `UpdateLiveVisuals()`'s `IsLive` branch, so a +recording made **without ever going live** carried no credit — exactly the creator's complaint. +It is now started **once in the `MainViewModel` ctor** and never stopped on live-state churn, so it +runs in every scene, reaches the preview, and rides the same `FramePump` into the stream and the +recording. The licence gate needs no live branch: `IsPremium`'s setter already pushes +`BrandFlashPresenter.Enabled`. + +**Trap that came with it (fixed):** `Enabled = false` stops the presenter's `DispatcherTimer`. +When `Start()` was per-go-live, the next go-live restarted it; with one app-lifetime `Start()` +nothing would, so a key entered mid-session would leave the credit dead until the process +restarted. The setter now restarts the timer when re-enabling while `_running`. + +Test-arithmetic trap, hit twice now: `Advance(5.1)` in one call can never observe a credit — +`Advance` opens *and* ages the presentation by the same delta, so it jumps the 2s window. Step at +1/30s like the real timer (the `Step` helpers). + ### Done this unit: recording save dialog — Cancel now discards `RenameRecordingDialog` was always correct (Enter → `DialogResult=true`, Cancel/X/Escape → `false`). @@ -97,19 +115,16 @@ The entire implementation is inside `#if DEBUG`. Release compiles to `DataRoot = ## Next -1. **Ungate the brand flash from `IsLive`** (TASK batch #2) — it must run in every scene and appear - in recordings; today it only starts in `UpdateLiveVisuals()`'s live branch, so a recording made - without ever going live carries no credit. -2. **Confine the ticker to the alert box** (#3) — it is still a global 1920×48 top overlay +1. **Confine the ticker to the alert box** (#3) — it is still a global 1920×48 top overlay (`BlitOverlay(…, 0, 0)`); needs a rect + scale/clip decision in both preview and output. -3. **Multi-instance** (#4) — route `FfmpegLocator._toolsDir` through `InstanceProfile`, and confirm +2. **Multi-instance** (#4) — route `FfmpegLocator._toolsDir` through `InstanceProfile`, and confirm the launch method: `dotnet run` while the first app holds `bin/…/ytLive.exe` fails with MSB3027 *before any instance starts* (a build-output lock, not a log conflict). `$env:YTLIVE_INSTANCE=2` + `dotnet run --no-build` in a second shell is the known-good path. -4. **Post-session efficacy report** (#5) — roll up `CurrentHealth` (dropped frames, duration, health +3. **Post-session efficacy report** (#5) — roll up `CurrentHealth` (dropped frames, duration, health message) when a stream or recording ends. -5. Push the commits when the creator asks. -6. Test-console chat UX (queued): don't clear the chat window, 20px right padding on the pull-out +4. Push the commits when the creator asks. +5. Test-console chat UX (queued): don't clear the chat window, 20px right padding on the pull-out input, Enter inserts a newline. -7. Decide the alert-gating copy question above. -8. Ship-checklist items live in `TASKS.md` → "1.0 gates". +6. Decide the alert-gating copy question above. +7. Ship-checklist items live in `TASKS.md` → "1.0 gates". diff --git a/Services/Compositor/BrandFlashPresenter.cs b/Services/Compositor/BrandFlashPresenter.cs index f7d4cb6..0df8b6f 100644 --- a/Services/Compositor/BrandFlashPresenter.cs +++ b/Services/Compositor/BrandFlashPresenter.cs @@ -98,6 +98,7 @@ public sealed class BrandFlashPresenter : IDisposable get { lock (_gate) return _enabled; } set { + bool restart; lock (_gate) { if (_enabled == value) return; @@ -108,8 +109,19 @@ public sealed class BrandFlashPresenter : IDisposable // presentation short instead of running it out ungated. _elapsed = PresentationSeconds; _timer?.Stop(); + restart = false; + } + else + { + // …but a DOWNGRADE (key entered mid-session) must bring the cadence + // back. The presenter now runs for the life of the app — Start() is + // called once at startup, not per go-live — so stopping the timer on + // the premium edge without restarting it here would leave the credit + // dead until the process is restarted, even after a valid key. + restart = _running; } } + if (restart) _timer?.Start(); } } @@ -119,6 +131,14 @@ public sealed class BrandFlashPresenter : IDisposable get { lock (_gate) return _enabled && _elapsed < PresentationSeconds; } } + /// Test-only: is the that drives + /// actually running? The premium/downgrade edge is the only + /// thing that ever stops and restarts it now that the presenter is started once for + /// the life of the app, and a synchronous test body cannot observe a real timer + /// tick — so assert the decision directly instead of sleeping through a 30–60s + /// interval. Never call this from product code. + internal bool IsCadenceTimerEnabled => _timer?.IsEnabled ?? false; + /// Begin the cadence: first flash /// after the call, then every rand(30s) + 30s. public void Start() diff --git a/TASKS.md b/TASKS.md index e6a0298..c1a41eb 100644 --- a/TASKS.md +++ b/TASKS.md @@ -79,9 +79,13 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw 1. ✅ **Recording save dialog: Cancel discards** — was silently saving under the default name. Enter accepts the default; Cancel/Escape/X **deletes** the footage and names the file if the delete fails. `MainViewModel.CompleteRecordingSave` (internal) + `ytLive.Tests/RecordingSaveDialogTests.cs`. -2. ☐ **Brand flash must run in ALL scenes, not only while live, and must be in recordings.** Currently - started/stopped from `UpdateLiveVisuals()`'s `IsLive` branch, so recording-only (never go-live) - shows no credit at all. Ungate the presenter from `IsLive`; the pump already carries it to both. +2. ✅ **Brand flash in ALL scenes + in recordings** — was gated on `IsLive` via + `UpdateLiveVisuals()`, so a recording made without ever going live carried no credit. The + presenter is now `Start()`ed once in the `MainViewModel` ctor and never stopped on live-state + churn; the licence gate rides on `IsPremium` → `Enabled` as before. Also fixed the + premium-edge/restart trap that app-lifetime exposed. Tests: `BrandFlashOutputTests` + (12 facts) incl. `Credit_IsComposited_WithNoLiveSession_AndSoARecordingCarriesIt` and + `ADowngradeMidSession_RestartsTheCadenceTimer`. 3. ☐ **Ticker confined to the alert box.** The announcement strip is a **global 1920×48 top overlay** (`AlertOverlayLayer` comment + `BlitOverlay(…, 0, 0)`), but the creator wants it over the *Stream Alerts video* — i.e. inside the `SourceType.AlertBox` rect, in preview AND output. Needs a rect diff --git a/ViewModels/MainViewModel.BrandFlash.cs b/ViewModels/MainViewModel.BrandFlash.cs index d6caed6..bfcd0af 100644 --- a/ViewModels/MainViewModel.BrandFlash.cs +++ b/ViewModels/MainViewModel.BrandFlash.cs @@ -31,6 +31,14 @@ public partial class MainViewModel /// the licence check lives inside the presenter so no caller can bypass it. public VideoFrame? BrandFlashFrame() => _brandFlash.Frame; + /// Test-only seam: advances the credit's clock deterministically, exactly + /// like . It proves the wiring + /// (the presenter is started, and NOT gated on IsLive) without waiting out + /// the real 5s DispatcherTimer — and because Advance only advances the + /// cadence while the presenter is running, a credit coming out of here is proof + /// that Start() happened at construction. Never call this from product code. + internal void AdvanceBrandFlash(double seconds) => _brandFlash.Advance(seconds); + /// Publish one credit frame to the preview pane, UI thread only (the /// presenter fires this from its own dispatcher tick, so the bitmap write is legal). /// Reuses one and diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 6218ee7..f9b7daa 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -429,18 +429,15 @@ public partial class MainViewModel : ViewModelBase PreviewGlowBrush = live ? IsTestStream ? "#9c6f1c" : "#e94560" : "Transparent"; PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0); - if (live) - { - // Cadence lives in the presenter: first credit 5s after go-live, then - // every rand(30s)+30s. It refuses to emit while premium. - _brandFlash.Enabled = !IsPremium; - _brandFlash.Start(); - } - else - { - _healthPollTimer.Stop(); - _brandFlash.Stop(); - } + if (!live) _healthPollTimer.Stop(); + + // NOTE: the branding credit is deliberately NOT started/stopped here. + // CREATOR RULING 2026-09-26 — it must appear in EVERY scene and in + // recordings, not only while streaming. It used to be gated on IsLive, which + // meant a recording made without ever going live carried no credit at all. + // The presenter is started once for the life of the app (MainViewModel ctor); + // the licence gate rides on BrandFlashPresenter.Enabled via IsPremium, so it + // needs no live branch — IsPremium's setter pushes it from any thread. UpdateSessionTimer(); } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index d67ab4a..6c70a47 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -152,6 +152,15 @@ public partial class MainViewModel : ViewModelBase _brandFlash = new BrandFlashPresenter(); _brandFlash.OnFrame = PublishBrandFlashPreview; + // Start the cadence ONCE for the life of the app, here. CREATOR RULING + // 2026-09-26: the credit must show in every scene and in recordings, so it + // cannot be tied to IsLive (that gate meant a recording made without going + // live carried no credit). The pump reads BrandFlashFrame() whenever it runs + // — go-live and local recording are the same FramePump, so one start covers + // output, recording and the preview pane alike. Enabled is seeded from the + // current licence state; ValidateLicenseAtStartupAsync re-pushes it later. + _brandFlash.Enabled = !IsPremium; + _brandFlash.Start(); _healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; _healthPollTimer.Tick += OnHealthPollTick; diff --git a/ai.md b/ai.md index 4988519..00269b3 100644 --- a/ai.md +++ b/ai.md @@ -1480,6 +1480,24 @@ crooked. **Escalation model (2026-09-01, creator decision):** the cadence is *obnoxiously* self-promoting. License activation still flips exactly one bit: `IsPremium` → flash off. Nothing else changes between free and paid, ever. + **Cadence is APP-LIFETIME, not go-live (creator ruling 2026-09-26):** the presenter is + `Start()`ed **once in the `MainViewModel` ctor**, not from `UpdateLiveVisuals()`. It used to be + gated on `IsLive`, which meant a recording made without ever going live carried no credit — the + creator's ruling: *"the made with llamacasty flash should appear in all scenes, not just live"* + and *"should also appear in recordings"*. One start now covers every scene, the preview, the + stream and the recording, because go-live and local recording are the **same `FramePump`** + (`Streaming.Operations.cs:68` and `:199` both call `StartAsync` with the same `brandFlash:` + delegate) — verified, not assumed; there is no second encoder path needing a "redirect". The + licence gate needs no live branch: `IsPremium`'s setter pushes `Enabled` from anywhere. + **Consequence of app-lifetime — the premium edge restarts the timer.** `Enabled = false` stops + the `DispatcherTimer` (cutting the advertisement mid-credit). Because `Start()` is no longer + called per go-live, nothing would ever restart it, so a key entered mid-session would leave the + credit dead until the process restarted. The setter therefore restarts it when re-enabling while + `_running` (`IsCadenceTimerEnabled` is the test seam). + **Test-arithmetic trap, hit again 2026-09-26:** `Advance(5.1)` in ONE call cannot observe a + credit — `Advance` both *opens* the presentation and *ages* it by the same delta, so a single + 5.1s step blows clean through the 2s window. Always step in 1/30s increments like the real timer + (the `Step` helpers in `BrandFlashOutputTests`). - **Paid (one-time perpetual license):** branding flash removed. `BrandFlashEnabled` is now a **derived, non-assignable** `!IsPremium` and the presenter's own `Enabled` gate is re-checked on every frame, so a key entered (or revoked) mid-credit cuts the advertisement on the next tick diff --git a/ytLive.Tests/BrandFlashOutputTests.cs b/ytLive.Tests/BrandFlashOutputTests.cs index b6f18ab..69faa6b 100644 --- a/ytLive.Tests/BrandFlashOutputTests.cs +++ b/ytLive.Tests/BrandFlashOutputTests.cs @@ -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 ---------- + + /// 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 UpdateLiveVisuals's IsLive branch, so a + /// recording made WITHOUT ever going live carried no credit at all. + /// 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 StartAsync with the same brandFlash: delegate) + /// the recording carries the credit too. + [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 */ } + } + }); + } + + /// 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 Start() again: a key entered mid-session used to leave the credit + /// dead until the process restarted. Re-enabling must resume the cadence. + [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 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); } + /// Same stepping, against the ViewModel's own presenter. + 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); + } + /// Alpha bounding box of everything the frame actually drew. private static (int MinX, int MinY, int MaxX, int MaxY) AlphaBounds(VideoFrame frame) {