Files
LlamaCasty/HANDOFF.md
T
gramps 9761b1d4be 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.
2026-09-27 09:33:30 -07:00

8.1 KiB
Raw Blame History

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".