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.
The credit existed only as a WPF BrandFlashLayer TextBlock in the preview at
25% opacity on a 300s timer. A viewer of the stream or the recording never saw
it, so "free tier shows branding" was not actually being delivered. The frame
pump has carried an unused per-frame flashFrame slot for exactly this.
Reverses the documented "Pre-GA posture" (ai.md Monetization), which kept the
flash preview-only so test VODs stayed clean. Creator ruling 2026-09-26: the
free tier has to be honest advertising, so it now reaches the broadcast.
One rendered VideoFrame feeds BOTH the frame pump and the preview, so the
creator's view cannot drift from what viewers get. Spec: 500ms in / 1000ms hold
/ 500ms out, first credit ~5s after go-live then every rand(30s)+30s, single
unwrapped line at a random spot inside the frame every time, neon white core
with a feathered red/blue halo.
Neon recipe is derivative work, per AGENTS.md: bright core + feathered
multi-radius halo with R and B split in opposite directions, from
https://nudaui.dev/components/neon-glow (layered text-shadow falloff),
https://help.maxon.net/rg/en-us/Content/html/Blurs-and-Glows-chromatic-glow.html
("with no displacement, the green channel is all but invisible behind your
white text" once R and B are split) and the OBS obs-stroke-glow-shadow
"feathered stroke with user-defined size and intensity". Neon blue is #4d8bff
rather than the theme's #0f3460, which renders near-black as a halo.
Also fixes a PRE-EXISTING bug found on the way: 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. SceneCompositor.Overlay is now internal (it copies before blitting, so
the bake is never mutated) and the shortcut composites overlays first.
The credit is deliberately NOT folded into SceneGraph.GetBakedBase:
SceneRegion.Matches compares element ids only, so a credit in the cached bake
would freeze there and never expire. Per-frame only, and Epoch is stamped
every read because the 8MB master buffer is recycled - without that the
paste cache would freeze a stale credit.
BrandFlashEnabled is now derived !IsPremium and non-assignable, and the
presenter re-checks the licence on every read, so a key entered mid-credit
cuts the advertisement on the next tick instead of letting it finish.
Tests: 10 new facts. 357 total, 356 pass; the one failure is the known
environment-flaky RealMouseDrag layer test (needs an uncovered desktop
session). Adds RealAppHost.RunAsync - a frame-pump test must await inside the
collection's shared STA thread or the whole suite deadlocks silently.
NOTE: scope-check.sh flags Helpers/InstanceProfile.cs, AppLog.cs,
TokenStore.cs, WebView2Manager.cs and InstanceIsolationTests.cs as outside
this commit. That is a false positive: it uses `git diff HEAD`, which cannot
distinguish a planned second commit in the same session from an unrelated
edit. Those files are the dev multi-instance unit, committed immediately
after this one - as is the InstanceProfile paragraph in ai.md, which shares a
file with the Monetization section corrected here.
The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).
- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
is always the UI thread; MainViewModel.AlertTicker writes it into one reused
WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
showed the text once, entering from the right and never crossing. Paced in reads per
alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
Duration: choose how long your alert stays on your stream" and "Text Delay", never a
scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
table_info migration, appended LAST in the SELECT because the Source reader is
positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.
Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.
Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
Four builds (89/90 + two) burned proving the alert video was PERFECT: real h264
1280x720, 240 frames decoded, alert audio in the live mix, and the shipped asset
byte-identical (md5 0ee1f496…) to the creator's llamacasty-dancingLlama-thankyou.mp4
with every sampled frame full bright content. Build 90's new diagnostics then showed
the frame reaching BOTH consumers every second for the whole clip —
`Alert preview: frame=680x200 a255` and `Output resolver: frame 680x200
playing=True` — an opaque, correctly-sized frame, handed over and never seen.
Root cause: Controls/PreviewPane.xaml keeps the per-element Image Collapsed unless a
DataTrigger fires, and there was no IsAlertBox trigger (only IsImageSource / IsChatBox /
IsWebSource / IsWebcam). The alert box's Image was therefore Collapsed forever. Chat
boxes rendered because they HAVE a trigger — that asymmetry is the whole clue, and it
is why removing/re-adding the layer could never fix it.
Fix: an IsAlertBox DataTrigger beside the IsChatBox one.
Two Good Dog tests, because the existing fakes had been hiding this:
- AlertBoxPreviewVisibilityTests (red `Expected: Visible / Actual: Collapsed`, now
green) drives the real MainWindow + PreviewPane and asserts the bound Image is
Visible — the first alert test that crosses the XAML at all.
- AlertClipOutputTests is the first test in the repo to run a REAL codec: pinned ffmpeg
generates a clip, the real AlertClipDecoder + AlertOverlayLayer + SceneCompositor
composite it, and the box rect must be a colourful picture that DIFFERS from idle. It
passed while the feature was broken in the app — which is exactly why it was needed:
it exonerated decode+layer+compositor and pointed the hunt at the last hop.
Also keeps the two once-per-second alert diagnostics (resolver + preview) that settled
it, pending the creator's call on whether to keep them.
Notes: the pinned BtbN ffmpeg has no libx264, so the test clip is -c:v mpeg4.
SceneCompositor.Render fills its base with opaque black, so the test compares against an
idle render rather than counting non-zero bytes. MediaSource has the same missing trigger
in PreviewPane.xaml — left unfixed as out of scope, noted in HANDOFF.
Full suite 324/324. Lesson recorded in MyMistakes.md rule (5): a producer that hands over
a correct frame has still delivered nothing — when output arrives and the user still sees
nothing, stop auditing the producer and audit the consumer's VISIBILITY.
TASK 43's alert box grows a real video celebration. Per-alert IAlertClipDecoder
(ffmpeg bgra + f32le pipes, real-time paced, disposed at drain) plays the shipped
Assets/alert-default.mp4 (stamped into the Asset table at startup) unless the
creator picks their own file — path reference only, never stored in the DB; the
six AlertRenderer animations stay the fallback. ~0.3s fade rides the alpha
envelope on straight-source copies (EOF freeze-frames then fades out); audio
forwards to a new AudioMixer alert ring (8s, 48k stereo) drained at unity — no
duck, creator ruling — scaled by volume × fade. An auto-composed marquee ticker
('Funder — Super Chat · $10.00', 140px/s) scrolls top-of-frame via a
FramePump._alertTicker seam through Render/CompositeLayers, mixed into the cache
signature (dynamic overlay, never baked). New Stream Alerts section in LeftPanel.
Derivative-work references (how OBS/Streamlabs alert boxes do per-alert video):
- https://support.streamlabs.com/hc/en-us/articles/217741147-Setting-Up-Your-Streamlabs-Alerts (custom image/video per alert type + variations)
- https://obsproject.com/kb/stream-tutorial-2-alerts (alert overlay as an on-screen zone)
- https://streamlabs.com/content-hub/widgets/alert-box (per-event alert playback)
Good Dog: AlertLayerVideoTests drives a fake IAlertClipDecoder through the whole
lifecycle in one pass (custom path wins, decoder spawns/disposes, fade envelope
0→127→255, audio volume×fade, ticker scrolls, EOF fade-drain to idle). It caught
the clip branch of Advance not clearing _current before AdvanceToNext — the layer
stayed IsPlaying after drain (MyMistakes post-mortem).
Full vstest 319/319; clean build 0 warnings; scope check green.
Creator: 'when the test slide-out is active, then any click off the div should
close the div — this behaviour applies to all tabs, not just TEST.'
MainWindow.Window_PreviewMouseLeftButtonDown already closed the Stream Settings
and YPP drawers on any click outside TextPullOutHost, but its close list skipped
TestSession — so the TEST drawer never dismissed on an outside click.
Fix: run TestSession.CloseDrawerCommand in the same outside-click branch.
All three drawers share the rail host, so the containment check is unchanged.
Good Dog: ONE integration test — the existing TestStream window section now
raises a window-root PreviewMouseLeftButtonDown (source = window => outside the
rail; deterministic, no OS mouse) and asserts the TEST and Stream Settings
drawers both close. Gate: clean build 0 warnings, full vstest 318/318.
Replace the external StreamElements feed with NATIVE YouTube events per the creator's
2026-09-23 question.
Chat parity half:
- YouTubeChatService decodes ALL SIX liveChat/messages event types into
ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/
giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay
rows are real now — and re-arms its one-shot poll on the server's
pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms,
maxResults=2000). ParsePage internal static seam + ChatPage record for
deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage
(TASK 41) preserved.
Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no
menus/polls, sub mention = chat row only, NO viewer count):
- New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate
mirroring chat; idle = transparent).
- AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/
count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/
chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card
drawing the 'made with LlamaCasty!' brand line.
- AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail
never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview,
Advance(double) as the deterministic test clock; ChatEventKind.None rows never
enqueue.
- Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews,
Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged
(member->NewMember, superchat->SuperChat).
Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) —
ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing;
six events play pairwise-distinct moving frames then drain to null.
Gate: clean build 0 warnings; full suite 316/317, the one failure
(LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the
known environmental class (real-mouse-drag no-ops with a game/fullscreen window
focused).
References (OBS/overlay ecosystem):
- streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList
- OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
Creator: 'when I attempt to refresh my YPP page, I get an error about not being
able to reach YouTube. Seriously?' Real log: channels.list failed (403) x3.
Root cause #1 (the 403): channels.list?mine=true&part=statistics,auditDetails,
contentDetails returns 403 insufficientPermissions when the token lacks the
youtubepartner-channel-audit scope — which the auditDetails part ALONE requires,
per the docs ('A request that retrieves the auditDetails part ... must provide an
authorization token that contains the youtubepartner-channel-audit scope'). That
scope is MCN partner tooling with a 2-week token-revocation rule; the app must not
hold it. TASK-39's 'current scopes suffice, no re-consent' slice-1 claim was wrong
for this part; mock-fake tests never touched the real API, so it shipped green and
403'd every refresh since 2026-09-22.
https://developers.google.com/youtube/v3/docs/channels/list
Fix: part=statistics,contentDetails only; standing flags removed from
ChannelStatsService -> YppStatSnapshot surface -> YppTrackerViewModel -> drawer,
replaced by an honest deep-link row ('Channel standing isn't exposed to YouTube
apps — check the Earn page'). YppSnapshot standing columns stay (schema-stable,
always false). channels.list failures now log the response BODY — the bare code
could not name insufficientPermissions, which is what made this undiagnosable.
Root cause #2 (found by the new Good Dog, masked by the 403): statistics come back
as JSON STRINGS ('350'); raw GetInt64() throws. Tolerant ReadInt64 (ValueKind-first;
JsonElement.TryGetInt64 THROWS on strings — type-in, not try-type).
Good Dog: ChannelStatsServiceTests.CaptureCurrent_RequestsNoAuditDetails_AndStillParsesTheSnapshot
(URL asserts no auditDetails + snapshot parses); YppPullOutTests fixture updated.
Recipes for both 403/scope and statistics-strings entered in MyMistakes.md.
Also shipped in the same commit (shared PreviewPane.xaml + ai.md): the audio-sync
status dot removal from the #77 feedback round (creator: 'what is the point of the
status light? Lose it') — IntToSyncBrushConverter deleted with it.
verify.sh gate: 0 warnings, 316/316 pass, scope-check clean.
Creator feedback on the live #77 build:
1. Avatar = 'much bigger, showing the youtube login account' — the streaming
account's face went 26→40 px (CornerRadius 20, 20pt fallback initial) so the
identity is readable across the room.
2. Gear moved a couple more places right (margin 6→18) — cleaner separation from
the wordmark after the TASK 42 relocation.
3. Uniform height: the REC|ON-AIR segments host, Start/Go Live, and Test now all
sit at Height=32 so the switch + its actions read as one family ('so the user
can intuit their purpose'). Running-world reality line untouched.
XAML-only; compile-verified (only the running-app exe-lock copy errors remain).
verify.sh full gate deferred until the app is closed (see HANDOFF).
The bar now renders ONE surface from the FIRST decision the creator makes —
Record or Stream — with each world being the whole bar. Creator directive:
"I wrote the fucking thing and I still can't figure-out how to do stuff",
"forget this one dog plan bullshit". OBS/streaming-tool reference for the
one-surface paradigm: https://obsproject.com (single mode toggle + context
actions) — cited per spin-guard habit; the go-live/test pattern follows
CEV (https://cev-desktop.aSean.xyz) precedent.
- Mode: single segmented REC|ON-AIR switch (SegmentToggle/SegmentLabel in
Themes/Controls.xaml); radio-exclusive world selection, one tap to flip.
- Record world: switch + Start Recording, zero YouTube identity.
- Stream world: switch + Go Live + Test + account zone right (Sign In until
connected, then avatar with Change Account/Logout context menu).
- Test is a child of Stream: procs only stream-armed AND signed-in.
- Running: one reality line "dot word elapsed" (REC/LIVE/TEST; green/red/
gold) + End; worlds + switch retire.
- Gear moved up from bottom bar, ~3 wordmark letters past the brand, single
click opens Settings/Bug/Feature/About menu (TASK 40 Unit B shipped early).
- Fix folded in: BeginTestStream now arms OnAirPillOn explicitly (unarmed
pump booted with zero encoder outputs -> "At least one output is required"
forced-stop cascade from TASK 41's pipeline).
- VM: world/reality props + RaiseTopBarModes() wired into StreamStatus,
pill, IsRecording, IsTestStream, IsConnected setters.
Doc: ai.md top-bar model, ViewModels/index.md, Controls/index.md, TASKS.md,
HANDOFF.md, new TASKS/task-42-top-bar-redesign.md; task-40 note.
Tests: TopBarModeTests.cs (new Good Dog), PillRadioTests + TestStreamTests
gates updated. Build 0 warnings; full suite 314/315 — single abort is the
pre-existing env-dependent RealMouseDrag test (Path of Exile 2 running)
Test button next to Start runs the real private-only go-live pipeline as a
session variant (IsTestStream): BeginTestStream -> extracted
StartStreamingSession(alsoRecord:false) shared with the dialog path — no Go
Live dialog, no 'last live' stamp, no recording. Gold top bar + TEST badge +
'End Test' button face; TEST drawer (third right-rail pull-out, three-way
exclusivity) auto-opens once the liveChatId resolves.
Chat tooling: Mock Chat Input sends a REAL liveChat/messages.insert
(YouTubeStreamService.InsertChatMessageAsync) that round-trips the real ~2s
poll and renders through the live overlay; simulated Subscriber/New
Member/Super Chat events inject through the poller's MessageReceived seam
(YouTubeChatService.InjectSimulatedMessage, ChatMessage.IsSimulated) — the
insert API only creates textMessageEvent, so non-text events are local-only
by design (ref: developers.google.com/youtube/v3/live/docs/liveChatMessages/insert).
Scopes youtube + youtube.force-ssl already cover insert; no re-consent.
Good Dog: ytLive.Tests/TestStreamTests.cs — coordinator protocol with fake
HTTP + real-window drawer exclusivity/gate. 0 warnings; 314/314 pass (clean
run; intermittent host abort is the pre-existing WinRT-webcam flake).
Docs ride in the same change: TASKS.md row 41, TASKS/task-41-test-stream-mode.md,
ai.md (YouTube Live API constraints), HANDOFF rewrite.
Current-OAuth-scope data, no re-consent: subscriber bars for both tiers (500 fan-funding /
1,000 ad-revenue; the 2027-02-01 doubling is versioned date-aware data in YppThresholds),
3-uploads/90d, live standing from channels.list auditDetails, self-reported 2FA/AdSense
checkboxes + deep links (Google 2-Step / youtube.com/earn), and the "what counts" education.
Every refresh appends a YppSnapshot (SQLite v10) so the analytics slice has history from day one.
- ChannelStatsService (channels.list statistics,auditDetails,contentDetails + playlistItems.list,
virtual CaptureCurrentAsync = test seam); YppTrackerViewModel mirrors LiveBroadcastFormViewModel.
- MainViewModel.Ypp.cs: Ypp property, ChannelStatsFactoryOverride seam, one-open-at-a-time drawer
exclusivity (both directions); account sign-in/out/restore hooks in Account.cs.
- PreviewPane.xaml: TextPullOutHost is now a drawer bank [broadcast][ypp][stacked white tabs];
outside-click collapse covers both drawers. MainWindow.xaml.cs updated.
- Wait - watchers: the whole feature already reviewed against YouTube's own docs (YPP Earn tab +
API availability) before building — see TASKS/task-39-ypp-journey-tracker.md.
- ONE integration test: YppPullOutTests (snapshot capture/persist + checklist round-trip +
drawer exclusivity). 313/313 pass, 0 warnings.
- Memory in same commit: TASKS.md row, ai.md Journey-tracker bullet (roadmap→reality), HANDOFF.md.
Slice 2 (Analytics yt-analytics.readonly re-consent → watch-hours/Shorts + velocity/ETA +
sparkline) is queued behind this, not merged.
Click-to-pin an open window as the full-bleed Live backdrop, via the existing
window:<hwnd> capture path (same render layer as game/desktop). Session-scoped:
window:/picker: keys heal to auto on reload (HWND-recycle hazard); pin wins
while the window enumerates, then auto-fallbacks (game → desktop → static); a
dead window's capture session heals to auto, no dead ends.
Fix: ScreenCaptureSourceFactory.Resolve hex-parsed "window:0x<Hwnd>" WITHOUT
stripping the 0x prefix — NumberStyles.HexNumber rejects it, so every pin
resolved null ("No capture target for this key") and the heal silently rolled
back. Same flaw in IsAliveWindowPin (pin-wins guard was dead). Both fixed.
Commit also carries an out-of-scope prerequisite: PillRadioTests.cs:105 had a
committed stray token ("...PrimaryStartButtonLabel soil;", CS1003) blocking the
whole test-project compile — token removed.
Plus the previous session's uncommitted pricing docs (one-time $29/$49) that
share TASKS.md/ai.md/HANDOFF.md.
List_PreviewMouseMove reorders StagedScene.Elements via RemoveAt/Insert,
bypassing SceneGraph's mutation surface, but never scheduled a save — the
new z-order was lost on restart. The drop now sets _dragReordered and
EndListDrag funnels it through MainViewModel.OnSceneElementsReordered()
(InvalidateBake + ScheduleSave, same background-save path as every other
mutation). Integration test (RealApp + temp DB) reproduces the exact
code-behind mutation and asserts the debounced save lands the new Source
SortOrder. Derivation reference: standard WPF ItemsControl drag-reorder
pattern (OBS layering semantics: bottom-most layer = index 0).
Chat's camera no longer greys Web Cam in Live (per-scene max, not app-wide);
TASK 26 superseded by creator directive (app-level resource model). Add Webcam
now places the existing app default without the picker; the picker runs only
for the initial selection. Removing the last placement keeps the identity
(_webcam never nulled) so Add stays offered. Startup pass adopts a solo camera
as the app default, so a clean layout offers the layer in Live/Chat at once.
Dynamic WebcamAddToolTip names the why (incl. the 'graduated to OBS' line).
Good Dog: WebcamMenuGateTests rewritten (real app + temp DB + camera seams) —
identity in Chat does not gray Live; Add in Live places same wc-1 no picker;
scene-with-placement stays gray; identity survives both removals (DB row 1).
WebcamStartupResourceTests single-lock fact asserts CanAddWebcam after adoption.
Docs same commit (ai.md supersession, TASKS.md, HANDOFF.md). 304/304, 0 warnings.
The creator couldn't add a webcam to Live (grayed app-wide) and nothing in the
app explained why. Ground truth from the live DB: one Webcam identity AND one
WebcamSceneConfig in the Chat scene — so the gray was the single-identity rule
working, but the reason was unobservable. This slice makes the webcam a
resource the app validates and locks, mirroring how OBS reserves its devices.
At startup we enumerate the OS once (ValidateWebcamResourceStartupAsync, fired
after LoadLayout, stored as WebcamStartupValidationTask for tests to await):
- 0 webcams -> app runs on, layer inactive, no alarm
- exactly 1 -> attempt CameraManager.AcquireAsync as an app-wide lock; on
failure show a persistent red alert at the bottom of the Layers panel
(WebcamLockAlert + Retry) that re-polls every 5s and clears itself the
moment the camera locks, or on any first real frame
- >=2 -> deliberately no auto-lock; camera selection belongs to the App
Settings dialog (gear) — next slice
CameraManager.IsRunning(deviceId) tells the pass a session already exists
(started OR still starting) so loaded identity configs count as the lock and
the pass never double-acquires. Test seams mirror LayoutPathOverride:
CameraEnumeratorOverride / CameraFrameSourceFactoryOverride so the startup
probe never touches real hardware under test.
Good Dog test: WebcamStartupResourceTests x3 — single-cam locked + app runs on,
zero-cams no-alarm/no-lock, lock-fails -> red alert -> Retry -> clears. Full
suite 304/304, build 0 warnings, scope-check passed.
Docs in-commit: TASKS Open items + ai.md Webcam section + HANDOFF rewrite.
Also corrects the record: 'NVIDIA Broadcast opens the webcam exclusively' was a
suspect-list claim (CameraConflictProbe reads process names only, no handles)
— not restated as fact.
The ~30Hz CapturePreviewAsync PNG poll capped real cadence at ~20Hz
(35–165ms full-HD encode+decode), so a 60fps widget still juddered at ~1/6
speed. Replaced polling with frame-driven capture of the composition
controller's root visual — the mechanism WebView2CompositionControl and
Flutter's webview_windows use (graphics_context.cc captures the root
surface_ visual via CreateGraphicsCaptureItemFromVisual; reference:
github.com/microsoft/Windows.UI.Composition.WinUI / flutter-internal
webview_windows). Frames now arrive at the renderer's own pace; capture
memory is epoch'd ring reuse + one crop-sized shared WriteableBitmap.
New Services/WebCaptureFrameSource.cs owns GraphicsCaptureItem + free-
threaded Direct3D11CaptureFramePool + session (Straight alpha readback,
per-frame FindContentBounds → CropBounds). WebView2Manager reworked around
per-session composition controllers + one UI-thread Compositor created via
the CoreMessaging CreateDispatcherQueueController P/Invoke (the 19041
projection lacks CreateOnCurrentThread); internal seam ctor
(Dispatcher, Func<string,IScreenCaptureSource>?) for hermetic tests.
CaptureScheduler.cs deleted; the three SetCaptureInterval cadence hooks
removed; InitWebView2() moved from MainWindow ctor to Loaded (a parent
HWND must exist for the composition controller); the hidden WebViewHostPanel
overlay deleted. TransparentBackgroundScript unchanged.
Tests: WebView2ManagerTests reworked — 4 control-size + scheduler tests
dropped, FindContentBounds tests moved to WebCaptureFrameSource, ONE
integration test (Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds)
drives the seam with a FakeWebSource + background-STA DispatcherPump.
Suite 293/293, 0 warnings.
NOTE: composition path NOT yet verified on a device — the take is the
next step. Web work committed locally only (no push per standing rule).
Docs same-commit: ai.md Slice 14 + supersede marker on Slice 11, HANDOFF,
MyMistakes (CoreMessaging DQ + namespace-landmine recipe), TASK 17,
Controls/ViewModels/Services indexes.
Positive offsets still delay the whole mix via the delay line (lip-sync fix);
negative offsets now ARM once at StartLive and drop |N| ms off the pipe's write
head so audio events land earlier when audio runs BEHIND video. Slider relabeled
AUDIO SYNC, Min −500, locked while live/recording (IsEditMode). LayoutStore and
VM clamp to −500..500.
OBS reference for eat-the-head negative sync: https://obsproject.com/kb/obs-studio/buffering-time (negative sync values pull audio earlier by discarding buffered player audio).
Test: StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead (6x0.9 head
must be eaten before 0.2 bed reaches the wire).
Roll-forward of today-slices 6fd1d9c onto the slice-8 base, two-loop hunk dropped.
Release counter (per-build GUID read as noise; +1 per commit from git rev-list,
baseline 241 -> #13, generated by GenerateBuildStamp; GUID demotes to startup.log).
Tests pinned to Label/#N >= 13; wordmark display test asserts the Label.
Flash fix (take 14 finding): consumer holds must never outlive depth x source
period — 4 slots at high refresh lap ~27ms vs a <=50ms compositor read, so a
recycled slot flashed its new frame over the lagged old one. All shared rings 4->8
(OBS/overlay precedent for ring discipline).
Camera producer now rotates an 8-deep ring + Epoch instead of a fresh ~3.7MB
array per device frame (110-220MB/s LOH churn); WebView2 capture reuses a canvas
scratch + 8-deep output ring + a reused WriteableBitmap instead of two fresh
arrays + a fresh bitmap per 10Hz tick. Paste cache stays identity-keyed (Epoch).
Take 6 measured render 35-41ms — WORSE than take 5's 25.5 — and the run could
not be attributed to a binary: exe mtime != build contents (incremental builds
serve stale exes; a source edit without rebuild is a silent old binary). Three
takes of a perf saga had been judged against builds nobody could prove.
- ytLive.csproj GenerateBuildStamp target: fresh GUID per compile (writes
obj/BuildStamp.g.cs -> Helpers/BuildStamp.Id/BuiltLocal). Deliberately defeats
incremental lies: every 'dotnet build' recompiles the app project.
- Wordmark shows the id as a superscript (TopBar.xaml, x:Static, 9px grey
BaselineAlignment=Superscript); startup.log records 'Build <id> (compiled
<time>)' so every take is cross-readable with the visible UI.
- FramePump stats split the tick: 'avg render Xms (resolve Y), avg submit Z' —
the resolver is timed separately (wrapper resolver on per-tick paths; bake
keeps the raw one) so take 7 names the hot half of 'render' with data.
- Fixed a latent transition-clock bug found on the way: lastTick now restarts
every frame (the branch rework had restarted it only during transitions,
letting a transition begun after idle complete instantly on its first Tick).
ONE integration test family: BuildStampTests (unit: shape) +
BuildStampDisplayTests (RealApp, namescoped FindName on TopBar proves the
wordmark SHOWS the id). 47/47 per-class green, clean build 0 warnings. Docs
same commit. User's top-bar/session spec (re-sent twice) + settled Q&A
decisions folded into HANDOFF Unit B — next work unit after take 7 verdict.
Take two (2026-09-01) three confirmed defects, all from creator feedback + the
new resolver logging:
1. NO WEBCAM IN OUTPUT: WebcamSceneConfig.WebcamId carries the identity GUID;
CameraManager keys sessions by DEVICE id — GetLatestFrame(guid) returned null
forever, the compositor silently dropped the layer while the preview (bitmap
path) looked fine. The resolver logging added in 8dcaee0 caught it red-handed.
DeviceKeyForWebcam maps identity->device through the _webcam singleton (identity
mismatch / unknown ids pass through). Test: WebcamOutputKeyTests.
2. START BUTTON VANISHED AFTER STOP: my pills-clear ruling met the old
ShowPrimaryStartButton gate (needed IsConnected or a lit REC pill) — signed-out,
pills-off = blank top bar, no session reachable. Start is now ALWAYS the idle
face; unarmed Start records (local needs no account — no dead-end no-op); the
Sign In button folds into Start's context menu ('Sign in to YouTube', shown while
disconnected) with a dedicated SignInCommand. SessionTeardownTests extended:
stopped session must leave a reachable Start.
3. TOP BAR ORDER (creator spec): [sign light] REC [pill] [sign light] ON-AIR [pill]
— each reality lamp now sits in front of its own intent switch (was: two pills,
then two orphaned dots). Sign labels keep the shared StatusSignText style.
Tests: WebcamOutputKey, SessionTeardown, FramePump, WebcamMenuGate, GlobalHotkey,
BroadcastPullOut — 14/14 across the six classes, clean build 0 warnings.
Phase 2 (refactor-commit-12..18) created Controls/ with 6 UserControls that
split MainWindow.xaml, but no index.md existed — a gap against the schema.md
rule (one index.md per code directory). Documents each control, the window↔
control contract, and the Phase-2 landmines (window-resources converter rule,
control xmlns, name-looked-up host stubs, dropped-Border).
OverlayHost.xaml(.cs) takes the window-root overlay dialog grid (settings/
bug/feature/about panels); BackToAbout_Click moved in via inherited DataContext.
WebViewHostPanel + ToastArea stay as thin window-root stubs so the WebView2 ctor
init and NotificationService InArea('ToastArea') name lookups are untouched.
This completes Phase 2: MainWindow.xaml is now a 123-line shell.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in same commit.
LeftPanel.xaml(.cs) takes the Row 1 Col 0 panel (layers + properties editor and
the live chat list). All property-editor/chat handlers moved into the control
(SourceNameBox edit, AddSourceButton menu, chat autoscroll self-subscribed on
Loaded, mirror/clip-shape, remove-source, background capture change/refresh,
opacity slider label, list drag-to-reorder, snapshot revert/accept family).
Window keeps only shell + routing: ctor wiring (_leftPanel), geometry, hotkeys,
taskbar overlay, BackToAbout, Window_PreviewMouseLeftButtonDown (uses
_leftPanel.IsClickInside), UpdateSelectionOverlay (calls _leftPanel.OnSelectionChanged).
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in same commit.
SceneThumbnailStrip.xaml(.cs) takes the Row 0 scene-picker strip (ItemsControl
over Scenes with staged/live border highlight); Thumbnail_MouseLeftButtonDown
moved into the control, transitioning via DataContext.TransitionToScene.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
PreviewPane.xaml(.cs) takes the center preview (Row 1 Col 1): the 1920x1080
canvas (OverlayCanvas/SelectionOverlay/BrandFlashLayer/SocialBarElement),
the text pull-out drawer, and the live-controls row (TRAX / game / mic meters,
volume sliders, speaker toggles, socials button) inside the preserved
glow/background Border.
Code-behind moved into the control (drive by DataContext==MainViewModel):
preview drag/select/resize/hit-test (Preview_Mouse{Down,Move,Up},
EndPreviewDrag, HitHandle, HitElement), webcam context-menu handlers
(WebcamMenu_ChangeWebcam/SetBorderAnimation/StopBorderAnimation/
FindBorderShape/CreateBorderAnimation/AddDoubleAnimation/SetBorderColor/
HideInScene/Remove), social-bar glow (SocialBar_MouseLeftButtonDown,
SocialsButton_Mouse{Enter,Leave}, SocialGlowTick), audio controls
(VolumeSlider_*, GameVolumeSlider_*, MicSpeaker_*, GameSpeaker_*,
SetSliderValueFromClick, FindParent), and TraxButton_*.
Window keeps the left source/property/chat panel + thumbnail strip + overlay
host, and routes the selection overlay and preview hit-testing through the
control's public surface (_previewPane.UpdateSelectionOverlay(bool),
IsClickInsidePreview/Drawer, PreviewPane.IsDraggableElement). BackgroundMenu_*
stayed in the window because the left-panel source context menu uses them
(the preview's own desktop/refresh menu binds RefreshCaptureCommand instead).
Preserved the outer preview Border (background/glow/margin) that was initially
dropped in the cut — caught and restored before commit. Pruned now-unused usings
(System.Net.Http, Media.Effects, Media.Animation, Media.Imaging, Shapes,
Threading) from MainWindow.xaml.cs.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
TopBar.xaml(.cs) takes the Row 0 top controls: logo/About button, REC/ON-AIR
pills + status signs, avatar, Sign In/Start/End buttons. The avatar fetch
(AvatarHttp/_loadedAvatarUrl/LoadAvatarImage/ShowAvatarFallback) moved into the
control with async semantics preserved; the window grabs the control via
FindName in its ctor and calls _topBar.RefreshAvatar() when IsConnected or
AccountAvatarUrl changes (previously OnViewModelPropertyChanged -> LoadAvatarImage).
AccountLight is pure binding (never touched by code-behind) so it moved with the XAML.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Also dropped the now-unused System.IO using from MainWindow.xaml.cs.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
HealthBanner.xaml takes the Row 1 YouTube-health banner (report-by-exception).
Converters: promoted the 7 value<->visibility converters (BoolToVis, Inverse,
NotNull, Null, Hidden, EnumToBool, AllTrueToVis) from MainWindow.Window.Resources
into Themes/Controls.xaml so extracted UserControls can resolve them. AGENTS rule:
controls cannot reach into a window's own named resources; they resolve from the
app-level merged theme instead. Promoted all of them (not just NotNullToVis) so
every pending extraction keeps working without window-level references.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 2 tracker updated in the same commit.
New Controls/ folder + namespace ytLive.Controls. BottomBar takes the
Grid.Row=3 footer: gear menu button (with its GearButton_Click handler moved to
the control's code-behind), centered stream-stats (bitrate/FPS/dropped/
duration/health), and the quality dropdown. All bindings inherit the window
DataContext; app-level styles resolve from Themes/Controls.xaml as before.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md tracker gains a Phase 2 section in the same commit.