The FFmpeg ProcessFailed event arrives off-thread. Start recovery on the UI
dispatcher, publish retry status there, and route StopStream back to the
dispatcher before changing bound state or alert collections. This prevents a
recovery exception from becoming an unhandled WPF CollectionView crash.
AttentionAlertStackTests covers worker-thread recovery reporting and stop.
616 tests green, clean build with 0 warnings.
Slice 4. 607 green, 0 warnings. Clips remain **off by default** and, while off, the ring is never
constructed — so no reader thread and no encoder output exist until someone asks for the feature.
- **F10** (`HotkeyId.SaveClip`, default F10, remappable in the hotkey dialog) snapshots the ring, writes
the file, prunes to the retention count and toasts the name. Empty ring → "nothing to clip yet"
instead of a zero-byte file; clips off → says so and points at Settings.
- **Settings**: Enable, length (10 or 15, default 10), keep (3/4/5, default 3). Persisted in the layout
store and **coerced on load** — a bad stored value falls back to a legal one, so the ring can never be
sized for a length the UI cannot show.
- The ring is sized from length × tier bitrate × 1.25 and rebuilt if either changes; it is created at
session start and dropped at stop, so memory is only spent while a show is running.
- The drawer states the boundary in the creator's terms: "A capture, not an editor: for trimming,
captions or a library, use a video editor."
Still to come: the replay layer itself (play once then hide in Live, loop the kept clips elsewhere).
595 green, 0 warnings. Completes TASK 55 (slice 1, log rotation, shipped 2026-10-22).
- **`AlertClipCleanup`**: the default alert clip is materialised into `%TEMP%` for ffmpeg to read, and
nothing ever deleted it — one orphan per profile that ever played an alert. Now: delete this session's
file in `Shutdown`, and sweep `ytLive-alert-*.mp4` at startup for anything a crash left behind. Only
the prefix we own is touched; a directory is never deleted.
- **`DiskSpaceGuard`**: a recording that dies of a full disk is a trust breach, so the numbers are a
pure policy — refuse to start under **1 GB** (toast; the stream still starts, recording does not),
warn under **5 GB** at start ("about eighty minutes" at 8 Mbps ≈ 3.6 GB/h), and one mid-show warning
under **2 GB** — the last useful moment before the encoder fails for a reason it won't explain.
Unreadable drives (a USB stick that just left) return null and never block a running recording.
Tests are policy-level on purpose: filling a real drive is not a test. `DiskHygieneTests` proves the
sweep only removes our own files and that the thresholds behave at both ends.
Slice 3. 589 green, 0 warnings.
- **`FramePump.ChangeEncoderAsync`** is a full pump stop/start, not an encoder swap. A swap leaves an
in-flight submit pointing at a disposed process, and that faults the loop — which ends the
broadcast, the one thing a quality step-down must never do. `StopAsync` already awaits the loop and
flushes, so this reuses the tested path and costs one short freeze, which the ladder accepts.
- **`MainViewModel.StepDown`**: one planner per broadcast, subscribed to `StatsReported`, unsubscribed on
every stop path. The pump thread only decides; the restart is posted to the dispatcher, because the
act stops the loop that raised the event.
- **The creator's choice is not rewritten**: `_encoderTierOverride` changes what the encoder emits while
the dropdown keeps showing what they picked, and the badge adds `· lowered` so the two never disagree
silently.
- Copy comes from `StepDownPlanner.Describe` (machine cause owns it, upload cause blames the upload);
the log gets the cause-tagged line for support.
Test: `EncoderRestartTests` runs a real pump with a fake encoder, changes the tier, and asserts a NEW
encoder started at the new fps/bitrate and produced a frame — the regression that would otherwise ship
as "it lowered the badge and the stream died".
Remaining for 33: shed our own capture cost before the fps step, and the session-report line.
Creator went live on the Debug build while the Release build was recording and got an empty
title/description, with the Stream Info tab filled in on screen. Both processes write to the same
profile: `BroadcastForm` is loaded ONCE at startup and each keystroke persists straight to the store,
so the instance that opened the dialog was holding the form as it was when it launched — before the
other instance saved those fields. The dialog copied from that stale copy.
`CreateGoLiveDialog` (new, extracted so it can be tested without showing a window) calls
`BroadcastForm.Load()` before composing the dialog. That is what the form's own doc comment always
claimed ("startup / Go Live refresh") but the refresh was never wired. It cannot lose an edit: every
keystroke is already persisted.
The dialog also logs `Go Live dialog: title='…' description=N chars`, so the next report of "the
dialog was blank" says immediately whether the values reached the dialog or never left the store.
Test: `BroadcastPullOutTests` writes metadata through a second `LayoutStore` handle (the other
instance) after the VM has loaded and asserts the dialog carries it. 572 green, 0 warnings.
Creator was recording on the Release build and hit "Couldn't start the audio — another copy of
LlamaCasty is already using the audio pipe" when going live on Debug. The guard did its job (no
crash), but the collision should not have been possible: the pipe name only suffixed when
`YTLIVE_INSTANCE` was set, and a Debug launched straight from the IDE has no id — so it asked for
the same `ytllive_audio` the Release build was serving.
DEBUG now always suffixes: the claimed instance id when there is one, otherwise the process id. Any
number of dev builds can run beside the Release app, and each Debug process is distinct from every
other. Release is unchanged — it resolves to the plain name, and the whole mechanism disappears in
`#if DEBUG` as before.
`TryStartAudioPipe` now logs the resolved name (`Audio pipe: …`), so the next report of this kind is
one grep instead of a code hunt.
Tests: AudioPipeInstanceTests pins that an id-less DEBUG process never resolves to the bare name;
572 green, 0 warnings.
Reported 2026-11-06: a Release instance crashed when clicking Go Live in test mode, while a Debug
instance was recording its window. The Windows event log gave the cause:
System.IO.IOException: All pipe instances are busy.
at System.IO.Pipes.NamedPipeServerStream.Create(...)
at ytLive.Services.Audio.NamedPipeAudioWriter.Start(...)
at ytLive.Services.Audio.AudioMixer.StartLive(...)
at MainViewModel.StartStreamingSession(...)
at MainViewModel.BeginTestStream(...)
at MainViewModel.StartFromPrimaryButton(...)
at System.Windows.Controls.Button.OnClick()
Two defects, both fixed:
1. **A button click could end the process.** `StartLive` throws when the pipe name is taken, and nothing
on the go-live path caught it. `TryStartAudioPipe` now catches, logs, toasts ("Another copy of
LlamaCasty is already using the audio pipe") and rolls the session back. Recording-only takes the same
path.
2. **The pipe name was a shared constant**, so a second instance could never go live on the same machine.
`InstanceProfile.AudioPipeName` appends the dev instance id (DEBUG only); Release keeps the plain name
so the normal single-instance case is unchanged. The encoder reads the same derived name, so mixer and
ffmpeg cannot disagree.
Tests: `AudioPipeInstanceTests` — a second server on one name throws (the collision is real), dev names
derive per instance, and a writer on a taken name throws rather than silently reusing the pipe.
Debug and Release both build clean.
The task asked for a `RewardEvents` table plus a backfill. That would have contradicted the published
promise that no viewer, member or donor information is retained after the session ends, and it would
have meant owning a ledger that YouTube already keeps.
So the report is DERIVED on demand instead:
- `Services/SuperChatEventService.cs` — `superChatEvents.list`, parsed for both event types (a sticker
must not be dropped), authorised by the existing `auth/youtube` scope, so no re-consent.
- `Models/SessionRewardSummary.cs` — rolls events up for the session window with **per-currency totals
that are never summed together** (a $5 and a €5 are not $10). Nothing is written down.
- The YPP drawer gains a **THIS SESSION** section: counts are neutral and always shown when there were
events; money appears only when the creator asks for it. `Show monetary metrics` is a drawer toggle,
persisted, **off by default** — a creator who never monetized is never shown a $0.00.
Also: money is labelled as estimates (removed/refunded events are excluded and totals can be restated
for weeks, not days).
Tests: `SessionRewardReportTests` — both reward types parse, the window makes it a session rather than
a channel report, currencies stay separate, an empty session shows nothing, a sticker-only session
still counts. `BackOnAirTests` moves into the serialised `MainViewModelLayoutPath` collection: it builds
a MainViewModel directly and was flaking against parallel tests that swap the static layout path.
FAST gate: 557 passed, 0 failed, 0 warnings.
"I forgot the VPN" used to create a broadcast on YouTube that nothing ever pushed to: the app looked
live, YouTube held an empty broadcast, and the creator had to work out what happened. Prevention beats
recovery here, so go-live now asks one question first.
- `Services/IngestProbe.cs` — `IIngestProbe` + `TcpIngestProbe`: parse the RTMP URL, connect to the host
(rtmp → 1935, rtmps → 443) with a 2 s timeout. Any failure answers "not reachable"; the point is a
fast unambiguous answer, not a diagnosis.
- `MainViewModel.IngestPreflightAsync` runs **before the first API call** whenever a cached ingest URL
exists; on a first run it probes once the stream is resolved, still before any broadcast is created.
A false answer logs, tells the creator ("Nothing was created on YouTube…"), and rolls the session
back — nothing to clean up on YouTube.
- The probe is a test seam (`IngestProbeOverride`) like the camera and chroma ones.
Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, URL parsing (rtmp/rtmps,
garbage), and the probe against **real sockets**: a listening port answers true, the same port closed
answers false and does so fast. FAST gate: 547 passed, 0 failed, 0 warnings.
A dead encoder used to run the whole stop path, so a one-second blip ended the broadcast. Now a dropped
push is relaunched against the same ingest while the broadcast is still alive.
- `Services/StreamRetryPolicy.cs` — immediate, +1 s, +2 s, +3 s. Minimum waits on purpose (creator
ruling): YouTube's grace is short, so every second not pushing is grace spent.
- `Services/StreamRecovery.cs` — the loop, with the audio pipe recreated before every attempt (its
server is single-instance and unusable after the old client dies, so a naive relaunch fails on a
missing input). After a push comes up it asks the API whether the broadcast survived: "complete"
ends recovery as `BroadcastComplete` instead of showing a live sign over a dead stream; an API
hiccup counts as recovered rather than ending a healthy session.
- `FramePump.EncoderFailed` — a dead push is recoverable; a compositor fault still goes through
`Failed` and ends the session, because retrying it would loop.
- `MainViewModel.Recovery.cs` — wires the steps, reports each episode into the left panel alert stack
(one row per episode, updated in place, log range painted), and cancels on Stop.
- Alert stack polish: a rule and a gap now separate the stack from the scene layers (they read as one
list before), and the licence indicator uses the creator's wording — `STEAM ID NOT PRESENT` /
`Steam ID Limit: 30fps` / `Click for More Info`, becoming `STEAM ID PRESENT` / `60fps` with a close
control once owned.
Tests: `StreamRecoveryTests` (schedule, recover on attempt 2 with the pipe restarted each time,
complete-broadcast honesty, unknown liveness, give-up, cancel, audio-restart fault),
`FramePumpTests` now pin that process death raises `EncoderFailed` and not `Failed`.
FAST gate: 538 passed (one documented pre-existing audio failure), 0 warnings.
The frame pump now submits crop-sized BGRA frames; FFmpeg scales them to final tier dimensions. Bundled scaler benchmark: 300 608x1080 to 1080x1920 frames in 0.435s (scaler-only).
Reference: https://ffmpeg.org/ffmpeg-filters.html#scale-1
Add independent AppLog message types and explicit Class 1/2 severity. Wrap and color log rows, pin Class 1 above Class 2, and let acknowledgement return entries to the current-launch timeline without changing file format or rotation.
Ref: https://logtape.org/manual/levels
The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.
Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)
Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).
Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.
Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
Adds SourceType.Scoreboard with the same per-scene eye toggle, drag and presence as every other
element — 'prominent on BRB, dialled back on Live' is geometry, not a second mechanism. Default
geometry is the strip: 1920 wide, 56 tall, bottom edge.
The output resolver returns the presenter's cached frame exactly like the chat box and alert box do,
and every session start clears the tallies. Preview publication writes one shared WriteableBitmap on
the UI thread (the pump thread must never touch one), refreshed only when a displayed value moves.
Guard: ScoreboardLayerTests pins the enum member, the geometry constants, the resolver case and the
session hook, so the layer cannot quietly become a special widget.
The state colours the view-models hand to bindings — top bar, REC/ON-AIR dots, health banner,
meter bands, toast tints — were hex literals in C#. They now resolve from Themes/Tokens.xaml
through `Helpers/ThemeBrush`, so the values live in one place. Without an Application (unit tests)
the helper returns a neutral grey: visibly wrong rather than silently plausible.
Four tokens the code needed and the document lacked, added to DESIGN.md (so the doc-is-the-contract
guard demanded their brushes): info-container #3a3b52, success-container #1d5c38,
warning-container #b8860b, test-container #9c6f1c (the test-stream gold bar).
Also fixed by this: the top bar still painted the RETIRED brand red #e94560 when live — the
standard's #ff5451 now, and `OnAirSignTests` (which pinned the old value) says so. BarOn/BarOff
became properties rather than static fields, because a static field would resolve tokens before
the Application exists.
Verified: DesignTokensTests, GameMeterHonestyTests, NotificationAreaIntegrationTests,
OnAirSignTests, SessionTeardownTests — 38 tests green.
Last inconsistency from the sweep: several VM-side state colours are still literals, and after
the palette moved they disagreed with everything around them — the top bar's LIVE state would
have painted the retired pink-red while the tile beside it used the standard red.
Values aligned (tokenisation of these sites is its own unit — they are strings bound to
Background/Foreground, so the fix is a VM->Brush refactor, not a find/replace):
- live / on-air / recorder dot: #e94560 -> #ff5451 (the standard red)
- error banner: #8f1f1f -> #93000a (error-container)
- audio meter bands follow the doc's stated thresholds: nominal cyan #38bdf8, caution amber
#ffb95f, peak #ff5451 — they were green/yellow/red, which contradicted DESIGN.md's own table
GameMeterHonestyTests pinned the old band colours; its intent (the meter scales by volume) is
unchanged, so the two colour expectations now name the new bands.
- TopBar: EDIT ToggleButton, spaced from the REC/ON-AIR segments
- MainViewModel: explicit EditingActive drives IsEditMode; YouTube go-live turns edit off, stop turns it on
- SceneThumbnailStrip: tiles fixed 196x130 and centered; dark background fills the column so its edge does not move on resize; strip row fixed 154px
- MainWindow: left column fixed at 360px; WIDTHS diagnostic log on resize
Not pushed.
- LeftPanel: SCENE LAYERS header with layer count; per-type blue icons; properties header gear; layers list min/max height; properties placeholder; list and properties indent
- SceneThumbnailStrip: names/hotkeys over the tile; PROGRAM badge with blinking red dot on the selected tile; orange border on selected and live tiles; fixed the element border override that hid the style border; reflag tiles on load
- LiveScene is now set on go-live, record start, and live transitions (it was never assigned, so IsLive never fired)
- Startup stages the scene named Starting
- DESIGN.md: color roles (red = YouTube/on-air/record, orange #ff8c3a = attention, amber = game audio/Super Chat)
- Staged live tile uses background art instead of the green LIVE placeholder
Not pushed.
Right-click in the live preview now procs a box that is actually visible and
populated with the zoomed region, and each Zoom View Shape sculpts the box per
spec. OBS-style derivative: magnifier box shows the composited region and dims
the surroundings (same pattern as OBS "zoom" / preview region highlights).
Root causes of the invisible box (two WPF traps, recorded in MyMistakes.md):
- Empty <VisualBrush Stretch="None"/> used as an OpacityMask paints nothing,
so the mask alpha is 0 and the WHOLE box (border included) went transparent.
- ZoomBox lived inside CanvasGrid while its VisualBrush sampled CanvasGrid — a
circular reference WPF renders as nothing. Hoisted ZoomBox to a sibling of
CanvasGrid inside a new PreviewCanvas wrapper (same 1920x1080 space).
Shape semantics (MainViewModel.ActivateZoom):
- Circle: square, click point = center.
- VerticalRectangle (phone): tall 9:16, click point = top-left corner.
- HorizontalRectangle (tablet): wide 16:9, click point = top-left corner.
- Boxes stay fully inside 1920x1080 via Math.Clamp.
UI: ZoomRectOutline/ZoomCircleOutline shapes replace the square Border;
circle clips the box to an EllipseGeometry; brush Visual released when inactive.
Zoom View Shape dropdown now uses the standard YtComboBox style.
Test (Good Dog rule, one integration test): ZoomBoxInteractionTests verifies
aspect/anchoring/clamp per shape and the matching outline+clip in the real
MainWindow. Full suite 404 passed, 0 warnings.
Memory: HANDOFF.md zoom state updated; WPF VisualBrush traps appended to
MyMistakes.md.
root cause (proven, not inferred): contentDetails.boundStreamId on
liveBroadcasts.insert does NOT bind the broadcast. Our diagnostic reads
lifeCycle=created privacy=private autoStart=true bound=NONE
boundMatches=NO-BINDING | streamStatus=active
created is Google's "incomplete settings" state, so enableAutoStart has no
bound stream to fire from; close-out then 403s invalidTransition because
created -> complete is not a legal edge. A reusable stream accepts bytes for
a broadcast that isn't watching it, so a perfectly healthy 99MB RTMP session
is exactly what an unbound broadcast looks like -- every observable pointed
away from the binding.
Google's migration guide states the rule outright: a liveBroadcast must be
bound to exactly one stream before it can start, and is not bound at creation.
That was in the docs read during the original design and was not applied.
Shipped:
- BindBroadcastAsync -> POST liveBroadcasts/bind?part=id&id&streamId (no body),
called from PrepareAndStartLiveAsync right after CreateBroadcast, before the pump.
Failure is fatal to the session: an unbound broadcast can never go live, so a
live-looking UI over one is a lie.
- boundStreamId removed from the insert body, and the streamId parameter removed
from CreateBroadcast. A parameter that looks meaningful but cannot affect the
outcome is what made the misdiagnosis possible.
- Replaced CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which
asserted the disproven behaviour and therefore laundered a guess into a
guarantee, with tests for the omitted field and the real bind endpoint.
Error messages (creator ruling 2026-10-04): bind failures are not all "check the
sign-in". Six documented reasons exist and only one is auth; BindFailureMessage
gives each its own text, and the fallback passes the raw reason through verbatim
so an unmapped reason stays greppable. liveBroadcastBindingNotAllowed matters
most -- it means our ordering is wrong, and a generic re-auth message would hide
a bug in our own code behind a plausible excuse. See MyMistakes.md.
ai.md: also corrects the TASK 44 claim that liveChatId is only populated once the
broadcast is live. The diagnostic shows it present while lifecycle is created.
That claim was inferred from a sample's active-status guard, which is not a spec.
Suite 395/395, 0 warnings.
The logs answered the question and closed off a whole branch of investigation.
The long run: broadcast inserted 14:17:04.577, ffmpeg to
rtmp://a.rtmp.youtube.com/live2/5f6z-... at 14:17:05.686, and ffmpeg's own
muxing summary at 14:18:56.473 reads
video:101229KiB audio:1419KiB muxing overhead: 0.202949%
~99MB of real video plus audio over 112s, which is what 8000+128 kb/s is
supposed to produce. No socket error, no 4xx from ingest, no reconnect. And the
broadcast still never left `ready`; close-out 403'd invalidTransition again. The
8s run behaved the same way, so "YouTube needed longer" is disproven — 112s is
longer.
So the RTMP path is provably healthy. That is the finding worth keeping: it
kills the entire "the push is broken" theory, which is where this had been
heading for a week. The break is between ingest being accepted and the broadcast
being told to start.
Which puts the binding in frame, and the app was structurally blind to it.
Google documents contentDetails.boundStreamId and says enableAutoStart fires
"when you start streaming video on the bound live stream" — both valid on
insert, neither restricted against reusable streams. But the migration guide
also says a broadcast "is not bound to any streams at the time that it's
created", and the failure that produces is invisible from the RTMP side
precisely because a reusable stream will happily accept bytes for an unbound
broadcast. Ingest succeeding proves nothing.
Hence LogBroadcastDiagnosticsAsync, riding the existing health poll rather than
adding a timer. One greppable line: lifecycle status, privacy, recording,
boundStreamId *compared against the stream id we are actually pushing*
(BoundMatches: yes / MISMATCH / NO-BINDING / unknown), boundStreamLastUpdate-
TimeMs, autoStart/Stop, liveChatId presence, the stream's own streamStatus and
activeLiveChatId, and the CDN settings we declared.
Deliberately self-limiting: _broadcastSeenLive latches on live/liveStarting so
the diagnostic shouts only while the broadcast is stuck and goes quiet once it
is healthy. A permanent per-tick log line would be noise; this is the opposite.
One test, per the Good Dog rule: BroadcastBindingTests pins the matrix and
specifically pins `unknown` when there is no stream id to compare against, so a
missing expectation can never be misreported as a match — that is the failure
that would make this diagnostic lie.
Suite 387/387, 0 warnings.
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.
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 open creator report ('I still cannot post a chat message in the TEST tab',
feedback 'Chat polling couldn't start — Mock Chat Input is disabled') was a single
cause: the liveChatId never resolved. Two YouTube API facts:
1. The id lives in snippet.liveChatId — contentDetails has no such property
(TASK 44 read part=contentDetails: could never resolve).
https://developers.google.com/youtube/v3/live/docs/liveBroadcasts
2. It only exists once the broadcast is LIVE — the official sample lists
broadcastStatus=active, and our fetch ran before the frame pump pushed RTMP
(enableAutoStart flips ready→live). A ready-state list legitimately returns
no id.
https://github.com/youtube/api-samples/blob/master/java/src/main/java/com/google/api/services/samples/youtube/cmdline/live/GetLiveChatId.java
Fix: GetBroadcastLiveChatIdAsync reads part=snippet and polls with a bounded
retry (10x/2s); PrepareAndStartLiveAsync starts the frame pump FIRST, then
resolves the id. Chat stays non-fatal. Docs ai.md/TASKS.md/HANDOFF.md +
MyMistakes recipe updated same commit. TASK 44.
Good Dog: ONE integration test (GetBroadcastLiveChatIdAsync_Polls_Snippet_...
) drives a broadcast that gains its id mid-retry and asserts every request used
part=snippet. Gate: clean build 0 warnings, vstest 318/318.
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.
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.
The recording is 60fps but WebView2 capture was a blind 100ms DispatcherTimer = 10Hz;
each captured web frame repeated ~6x into the file caps web animation at the capture
rate, not the page's (user: 'the animation appears to be too slow').
- New CaptureScheduler (Services/CaptureScheduler.cs): per-session dispatcher timer
that DROPS a tick while a capture is in flight (latest-wins, never queues) — the
guard that makes a higher cadence safe: concurrent full-HD PNG CapturePreviewAsync
calls (~10-30ms each, slow per WebView2Feedback#20) would stack CPU and publish
stale-after-fresh. Effective cadence = max(interval, capture duration).
- Cadence: SetCaptureInterval(33) on record/stream start, (200) idle — applied via
MainViewModel.Streaming.Operations.cs.
- De-throttle the hidden page: shared CoreWebView2Environment created BEFORE
EnsureCoreWebView2Async with --disable-backgrounding-occluded-windows
--disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion.
Off-screen WebView2 is a hidden page when the host window is unfocused/covered and
Chromium then parks rAF and clamps timers to ~1s (WebView2Feedback#1172/#3070,
Chrome-88 timer-throttling blog).
- Telemetry: first 30 captures per session log elapsed ms (PNG encode + decode) to
startup.log — that decides whether ~30Hz stays or drops to ~20Hz; the FramePump
drops frames (never time-lapses, slice 10) if UI-thread GC churn starves it.
- ONE test: CaptureScheduler_Drops_Ticks_While_Capture_InFlight_And_Resumes
(deterministic TCS-driven, no WebView2 runtime). Suite 290/291 — sole failure the
pre-existing compositor pixel test.
- Docs same-commit: ai.md slice 11, MyMistakes.md, HANDOFF.
References: https://github.com/MicrosoftEdge/WebView2Feedback/issues/1172https://github.com/MicrosoftEdge/WebView2Feedback/issues/3070https://github.com/MicrosoftEdge/WebView2Feedback/issues/20https://developer.chrome.com/blog/timer-throttling-in-chrome-88
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.
Creator ruling 2026-09-01 (stuck REC pill after the failed first recording):
- StopStream clears RecordPillOn + OnAirPillOn — intent resets with reality.
- OnFramePumpFailed: dispatcher-marshalled FULL rollback via StopStream (was:
toast + Error-status only when live — record-only sessions zombied with
IsRecording=true over a dead encoder; the 20:34 attempt proved it). Toast copy
says 'Recording stopped' vs 'stream pipeline stopped'.
- Same zombie class in the three go-live prep failure branches: StreamStatus.Error
limbo replaced by StopStream() (audio loop + recording + pills unwind; a created
broadcast still gets the close-out).
- AudioMixer.StopLive made explicitly idempotent (Stop() twice, rollback paths that
never reached StartLive).
- Seams: OnFramePumpFailed + IsRecording setter internal (InternalsVisibleTo; test
pattern mirrors LayoutPathOverride).
ONE integration test: SessionTeardownTests (real window + temp DB: pump death with
lit pill -> no zombie, no End button, Offline). AudioPipelineTests confirmed to hang
STANDALONE (pre-existing, the declared-known audio class) — ai.md test-count
paragraph corrected to stop claiming a suite total that cannot currently be measured.
The specced 'End stream -> transition(complete)' call never existed: stopping relied
entirely on enableAutoStop (viewers sat on a frozen stream-offline for ~a minute, VOD
finalized late). Found during the 2026-09-01 recording-verification pass while the
creator asked 'if there's proper close-out info yt needs, we'll provide it?'
EndBroadcastAsync POSTs liveBroadcasts/transition?broadcastStatus=complete&id=..&part=status,
called after the pump stops (RTMP EOF first) and only when a live session had a broadcast —
record-only stops stay offline. invalidTransition/410 (autoStop already ended it) is logged
and returned as an error string, never thrown: a stop must never fail over close-out.
ONE integration test (URL shape + never-throws on 403). ai.md/TASKS.md design lines marked
SHIPPED with the map-lie note.
Ref: https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition
The original premise (extract FFmpegEncoder / StreamHealthMonitor / FramePump
classes) was a no-op — those already exist as Services/Encoder/FfmpegEncoder.cs
and Services/Encoder/FramePump.cs. The honest functional seam was the local
recording-output concern, which was interleaved with live-stream orchestration:
- New MainViewModel.Recording.cs (121): StartRecordFile, FinalizeRecordingAsync,
UniquePath, ChooseRecordFolder, ResetRecordFolder, DefaultRecordFolder + the
recording fields (_recordFolder, RecordFolderDisplay, _activeRecordPath,
_recordStartTime, _recordLength).
- MainViewModel.Streaming.Operations.cs 475 -> 377 (keeps live session lifecycle,
health, visuals).
- MainViewModel.Streaming.cs 328 -> 323 (keeps pills/state/properties intact).
Same partial class — all MVVM bound-property glue untouched, no behavior change.
Build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md tracker updated in same commit.
Streaming.cs 789 -> 330. Moved the go-live/record/health operation bodies into a
new MainViewModel.Streaming.Operations.cs partial (459 lines: StartSession,
BeginRecordOnly, BeginGoLive, StartRecordFile, PrepareAndStartLiveAsync,
PollHealthAsync/OnHealthPollTick/ApplyHealthIssue, StopStream,
FinalizeRecordingAsync, UniquePath, Choose/ResetRecordFolder, DefaultRecordFolder,
OnFramePumpFailed/HealthUpdated, ApplyHealth, ResetHealth, BuildEncoderOptions).
Streaming.cs keeps state, quality/resolution tiers, session timer, command
declarations. Pruned dead usings in both files.
Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures.
Docs: ViewModels/index.md Phase 3 tracker updated in same commit.