9761b1d4be
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.
131 lines
8.1 KiB
Markdown
131 lines
8.1 KiB
Markdown
# HANDOFF — current state
|
||
|
||
**Branch:** `main` (pre-1.0, no feature branches). **Last pushed:** `7f14ffb`.
|
||
**This session's work is COMMITTED LOCALLY, NOT PUSHED** — **four** commits ahead of `origin/main`
|
||
(`fae8ec5`, `5aedca7`, `f5a9d46`, `de81fa3`) plus this unit. Push only when the creator says so.
|
||
|
||
## The 2026-09-26 proof-of-concept round (creator reporting)
|
||
|
||
Context: **no YouTube private test recordings are being saved**, so the creator is judging
|
||
compositing from **local recordings**. They assumed the live path needed a separate "redirect" —
|
||
**it does not, and that is verified, not assumed:** one `_framePump` is constructed in the
|
||
`MainViewModel` ctor with a single `brandFlash:` callback, and both `Streaming.Operations.cs:68`
|
||
(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`).
|
||
**The caller was not**: `FinalizeRecordingAsync` only overwrote `stem` when the dialog returned true
|
||
and then ran `File.Move` **unconditionally**, so a cancelled dialog silently saved the recording
|
||
under the default name. Extracted the decision into internal
|
||
`MainViewModel.CompleteRecordingSave(startPath, dir, autoStem, chosenStem, creatorSaved)`:
|
||
|
||
- `creatorSaved == false` (Cancel/Escape/X) → **delete the temp file**; the videos folder is left
|
||
empty. A failed delete returns `DiscardFailed` and the toast names the file + folder.
|
||
- `creatorSaved == true` (Save, or Enter on the pre-filled default) → `File.Move` to the final name.
|
||
Blank box still means "keep the auto name" — but only on an explicit Save.
|
||
|
||
Test: `ytLive.Tests/RecordingSaveDialogTests.cs` (5 facts, real temp files, no WPF needed) — the
|
||
headline asserts Cancel leaves the directory **empty**, not merely "the stem is unchanged".
|
||
Lesson recorded in `MyMistakes.md` (falsy `ShowDialog()` falling through into a side effect).
|
||
|
||
## Two earlier work units (committed)
|
||
|
||
### 1. Branding flash is now composited into the OUTPUT (TASK 36 shipped)
|
||
|
||
It used to be a WPF `BrandFlashLayer` TextBlock in the preview at 25% opacity on a 300s timer — a
|
||
credit the creator could see and **no viewer ever could**. Now one rendered `VideoFrame` goes to
|
||
**both** the frame pump and the preview, so they cannot drift:
|
||
|
||
- `Services/Compositor/BrandFlashPresenter.cs` (new) — neon raster (white core + feathered red/blue
|
||
halo, channels split opposite), 2s envelope, random on-canvas placement per presentation,
|
||
licence gate re-checked on every read.
|
||
- `ViewModels/MainViewModel.BrandFlash.cs` (new) — `BrandFlashFrame()` for the pump +
|
||
`PublishBrandFlashPreview()` for the pane.
|
||
- `FramePump` gained `brandFlash:` → the existing per-frame `flashFrame` slot, and mixes it into the
|
||
C4 cache signature. `SceneCompositor.Overlay` is now `internal` for the shortcut path.
|
||
- `PreviewPane.xaml`: `BrandFlashLayer` → `BrandFlashElement` (bound to the published frame).
|
||
- `BrandFlashEnabled` is derived `!IsPremium` and no longer assignable.
|
||
|
||
Spec met: 500ms in / 1000ms hold / 500ms out, first credit ~5s after go-live then every
|
||
`rand(30s)+30s`, single unwrapped line, random spot every time, fully on canvas.
|
||
|
||
### 2. Dev-only: two instances side by side
|
||
|
||
`Helpers/InstanceProfile.cs` (new). Set `YTLIVE_INSTANCE=<id>` and that process gets a private
|
||
`%APPDATA%\ytLlive\instances/<id>/` root for the **layout DB, auth file, startup.log and the
|
||
WebView2 user data folder**. Chromium locks that folder exclusively — without this the second
|
||
instance does not start at all. Recording folder and the ffmpeg `tools` cache stay shared on
|
||
purpose; global hotkeys stay un-namespaced.
|
||
|
||
**Usage:** `$env:YTLIVE_INSTANCE=2; dotnet run`
|
||
|
||
The entire implementation is inside `#if DEBUG`. Release compiles to `DataRoot => DefaultRoot` +
|
||
`WebViewDataFolder => null`; the call sites are unconditional so Release cannot drift.
|
||
|
||
## Bugs found and fixed along the way
|
||
|
||
- **Pre-existing, not mine:** `FramePump`'s **fully-static shortcut** stretched the cached bake and
|
||
returned it, silently discarding **every per-frame overlay** — the social bar and the alert ticker
|
||
were already lost there, not just the flash. Now composites overlays onto a copy before stretching.
|
||
- **Cadence bug caught by my own test:** the interval was an absolute deadline, not a period, so
|
||
gaps collapsed (6.7s / 3.5s / 2.8s instead of 30–60s). Fixed.
|
||
- `BrandFlashPresenter` recycles one 8MB master buffer and **must** stamp `VideoFrame.Epoch` per
|
||
read, or the paste cache freezes the credit. It does.
|
||
|
||
## Test state — VERIFIED
|
||
|
||
- **357 total, 356 pass.** 18 new facts (10 brand flash, 8 instance isolation).
|
||
- The one failure is `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
|
||
and it is **flaky by environment, not a regression** — 2 fail / 1 pass in isolation. Its own doc
|
||
comment: "Requires an interactive desktop session: if the window is covered or the session is
|
||
locked, the pointer no-ops and the drag never lands." The symptom matches exactly (order
|
||
unchanged). **Run the suite with ytLive CLOSED** (see `MyMistakes.md` 2026-08 era note).
|
||
- `RealAppHost.RunAsync` was added for this: a frame-pump test MUST `await` inside it. A blocking
|
||
wait in `RealAppHost.Run` occupies the one shared STA thread and hangs the whole suite with no
|
||
output. **Always background a `vstest` run and poll the log** — foreground piped `vstest` returns
|
||
nothing in this shell even on success.
|
||
|
||
## Landmines
|
||
|
||
- A stale `ytLive.exe` (PID 2544) locked `bin/.../ytLive.exe` and broke `dotnet run` with MSB3027.
|
||
Killed. **If the build fails to copy `ytLive.exe`, check for a running instance first.**
|
||
- `GlobalHotkeys`: two instances registering the SAME global hotkey — Windows refuses the second.
|
||
Left alone deliberately.
|
||
- `OverlayHost.xaml` says Polar unlocks alerts too; alerts are not gated. Unresolved, queued.
|
||
|
||
## Next
|
||
|
||
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.
|
||
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.
|
||
3. **Post-session efficacy report** (#5) — roll up `CurrentHealth` (dropped frames, duration, health
|
||
message) when a stream or recording ends.
|
||
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.
|
||
6. Decide the alert-gating copy question above.
|
||
7. Ship-checklist items live in `TASKS.md` → "1.0 gates".
|