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.
8.1 KiB
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 returnsDiscardFailedand the toast names the file + folder.creatorSaved == true(Save, or Enter on the pre-filled default) →File.Moveto 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.FramePumpgainedbrandFlash:→ the existing per-frameflashFrameslot, and mixes it into the C4 cache signature.SceneCompositor.Overlayis nowinternalfor the shortcut path.PreviewPane.xaml:BrandFlashLayer→BrandFlashElement(bound to the published frame).BrandFlashEnabledis derived!IsPremiumand 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.
BrandFlashPresenterrecycles one 8MB master buffer and must stampVideoFrame.Epochper 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 (seeMyMistakes.md2026-08 era note). RealAppHost.RunAsyncwas added for this: a frame-pump test MUSTawaitinside it. A blocking wait inRealAppHost.Runoccupies the one shared STA thread and hangs the whole suite with no output. Always background avstestrun and poll the log — foreground pipedvstestreturns nothing in this shell even on success.
Landmines
- A stale
ytLive.exe(PID 2544) lockedbin/.../ytLive.exeand brokedotnet runwith MSB3027. Killed. If the build fails to copyytLive.exe, check for a running instance first. GlobalHotkeys: two instances registering the SAME global hotkey — Windows refuses the second. Left alone deliberately.OverlayHost.xamlsays Polar unlocks alerts too; alerts are not gated. Unresolved, queued.
Next
- 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. - Multi-instance (#4) — route
FfmpegLocator._toolsDirthroughInstanceProfile, and confirm the launch method:dotnet runwhile the first app holdsbin/…/ytLive.exefails with MSB3027 before any instance starts (a build-output lock, not a log conflict).$env:YTLIVE_INSTANCE=2dotnet run --no-buildin a second shell is the known-good path.
- Post-session efficacy report (#5) — roll up
CurrentHealth(dropped frames, duration, health message) when a stream or recording ends. - Push the commits when the creator asks.
- Test-console chat UX (queued): don't clear the chat window, 20px right padding on the pull-out input, Enter inserts a newline.
- Decide the alert-gating copy question above.
- Ship-checklist items live in
TASKS.md→ "1.0 gates".