Files
LlamaCasty/HANDOFF.md
T
gramps 938c5b3de4 alert ticker: draw it inside the Stream Alerts box, not as a top-edge bar
Creator 2026-09-26: "the ticker should appear over the stream alerts video, not over
the entire preview window". It was a GLOBAL 1920x48 bar blitted last at a hardcoded
(0, 0) -- it covered the whole preview width, not the alert video.

The strip is now rendered at the alert box's own size and the box's origin travels on
the frame in a new VideoFrame.Placement ((int X, int Y)?). OriginX/OriginY default to
0 when Placement is null, so every full-canvas overlay (the branding flash) is
unaffected. Every ticker blit site now reads tickerFrame.OriginX/OriginY instead of a
literal 0, 0 -- SceneCompositor x2 and FramePump's static-bake Overlay path -- and
PreviewPane.xaml's AlertTickerElement binds the same rect (AlertTickerLeft/Top/
Width/Height), so the preview cannot drift from the recording the creator is judging.

Why the position rides on the frame rather than in a full-canvas frame: a 1920x1080
overlay is 8.3MB of large-object-heap garbage per tick, ~2.5GB churned over one 10s
alert at 30fps. A box-sized strip is ~370KB. The branding flash does render
full-canvas but RECYCLES one 8MB buffer and bumps Epoch, so it never allocates per
frame; the ticker allocates per call, so it has to stay small.

Also fixed: CopyStrip now clips ROWS to the target height. The pill rasterises at its
natural 48px, so an alert box shorter than that would have written past the end of the
target buffer once the strip became box-sized.

No alert box in the scene now means no ticker at all -- there is no global position
left for it, and this is the guard against the old bar quietly coming back.

Tests (3 new facts, 27 in the file):
  ComposedOutput_PutsTheTickerInsideTheAlertBox_NotAtTheTopEdge -- renders through
    the real SceneCompositor and asserts the pixels land at (620, 430) and NOT at the
    top-left corner. The creator is judging compositing from local recordings, so the
    OUTPUT side is the side that needed proving.
  ASceneWithNoAlertBox_PublishesNoTicker
  ABoxShorterThanTheStrip_ClipsRowsInsteadOfOverrunning
Updated AlertLayer_PublishesARealTickerFrameToThePreviewSink, which asserted the old
1920 width from a box with no geometry (defaulted to 1px); it now uses the product's
own default box (680x200 at 620,430, MainViewModel.Sources.cs:54-57) and pins the
frame size and origin.

Full suite 367/367.
2026-09-27 09:43:26 -07:00

9.2 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 alert ticker draws inside the Stream Alerts box

Was a global 1920px bar pinned to the top edge; the creator wants it "over the stream alerts video, not over the entire preview window". The strip is now rendered at the alert box's own size and the box's origin travels on the frame in a new VideoFrame.Placement ((int X, int Y)?, with OriginX/OriginY defaulting to 0 so full-canvas overlays are unaffected). Every blit site now reads tickerFrame.OriginX/OriginY instead of a literal 0, 0 — SceneCompositor ×2 plus FramePump's static-bake Overlay path — and PreviewPane.xaml's AlertTickerElement binds the same rect (AlertTickerLeft/Top/Width/Height), so preview and output can't drift.

Design notes: the position rides on the frame rather than in a full-canvas frame because a 1920×1080 overlay is 8.3MB of LOH garbage per tick (~2.5GB over one 10s alert); the strip is ~370KB. CopyStrip now clips rows to the target height — the pill rasterises at 48px, so a shorter box would have written past the buffer. No alert box in the scene ⇒ no ticker.

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. 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.
  2. Post-session efficacy report (#5) — roll up CurrentHealth (dropped frames, duration, health message) when a stream or recording ends.
  3. The creator's own eyes on the output — the ticker-placement and brand-flash changes are proven by unit tests and the compositor, but the final proof is a local recording, since no YouTube private test recordings are being saved.
  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".