Creator ruling 2026-11-13: "the app does three things. builds. records. live-streams." So the bar holds
three buttons that never disappear; the active one is lit and runs its own flow in place, and the other
two dim but stay clickable, so there is no state where the creator cannot see where they are or how to
step back.
Build [Build Scenes] -> editing on, nothing to start
YouTube [Live][Private] -> [Start] -> [Stop] (Private = the private-visibility test stream)
Record [Start Recording] -> [● REC hh:mm:ss][Stop Recording]
Arming and starting are separate presses on purpose (creator): Start is the last chance to tell someone
"check you are ready". Collapsing them would save one click and lose that prompt.
This replaces the two-world model (BarWorld) and its output stage outright, including the chips added
earlier today. The old pills remain the underlying state (OnAirPillOn/RecordPillOn) so the start, stop and
step-down paths are untouched; the activity only decides what the bar shows.
Closing the app no longer waits 6 s for a session that cannot stop: it waits ~2 s, then clears the state
and closes anyway. A window that refuses to close is worse than a stale flag — and that refusal is what
hung the suite in ZoomBoxInteractionTests, which sets IsRecording and closes a real window.
Test: TopBarActivityTests pins all three flows, the arm-then-start split, and that stepping back to Build
forgets the YouTube choice instead of carrying it. TopBarWorldTests is retired with the model it tested.
Build 730 (creator):
- Left pill while recording: solid #2ECC71 dot pulsing 1.0 <-> 0.2 on the existing one-second timer, and
"REC: hh:mm:ss" in Consolas so the digits cannot jitter the pill's width. Idle face unchanged.
- Right pill while recording: a stop square and "Stop Recording"; clicking it runs the normal stop, which
halts and resets the clock and returns the pair to "Local MP4" / "Start Recording".
- The separate reality line (dot + REC/LIVE + elapsed + End) is now LIVE-ONLY: a recording would otherwise
print the same three facts twice. RunningVisible is IsLive, and ShowOutputStage no longer hides itself
for a recording (it must be on screen to carry the clock and the stop).
Two rules collided and are now separated rather than merged: a creator's Stop on a recording restores the
Local MP4 pair (build 730), while a pipeline FAILURE still clears both pills (the 2026-09-01 zombie rule).
StopStream(afterFailure:) carries the difference, and the pump-failure path passes it.
RealAppHost.Run was rethrowing with `throw failure;`, which destroyed the original stack — a failing
assertion inside the action reported the wrapper's line instead of its own. ExceptionDispatchInfo now
preserves it; that is how the two failures above were located in one pass instead of five.
680 pass (one audio-pipe instance flake under full-suite load, passes alone), 0 warnings.
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
Build 729 (creator):
- "press <esc> to return" loses the word "press"; the label is "Record / Go-Live".
- The bar takes focus on "Build scenes" and Tab cycles between the two halves only
(KeyboardNavigation.TabNavigation="Cycle"), never off into the window.
- Entering Record / Go-Live starts with NOTHING chosen — the output switches are a fresh decision,
not a remembered one.
- Three states: nothing chosen [YouTube][Local MP4]; YouTube chosen [YouTube(red)][Live Stream][Private
Stream]; Local MP4 chosen [Local MP4(red)][Start Recording] → [End Recording] while recording. Only the
chosen output is red; the action chips never highlight. Choosing Local MP4 takes the left slot and the
YouTube chip disappears with it, which is what "slides to the left, replacing it" describes.
"Private Stream" is the private-visibility stream — the same thing this app has always called the test
stream (creator's clarification, so the wording is deliberate, not a new broadcast type).
Output chips are one style with a Tag-driven trigger (OutputChip + OutputChipIcon), so a chip's red face
and its glyph colour can't drift apart; the one-alpha hit-area colour is a token, because
DesignTokensTests rightly refuses literals in theme files.
680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
Implements the creator's mock (2026-11-12) for the world segment: pill track (#1E222B, radius 12,
padding 4) holding two equal halves; the active half sits on a lighter pill (#2E3440, radius 8) with
#E2E8F0 bold text and a coral (#F26D6D) icon; the inactive half is #8892A4 on a one-alpha black face so
the whole area hit-tests; hover tints #252A34 on the inactive half only, so the active one never looks
clickable-but-dim. Labels are "Build scenes" / "Record / go live".
Two deliberate deviations from the pasted recipe, both to honour this repo:
- State is the existing BarWorld binding plus a command, not a code-behind SetActiveMode — one source of
truth, and the change still raises the same property-changed notification a mode event would.
- Styles live in Themes/Controls.xaml (SegmentHalf, SegmentIcon) with colours as tokens in Tokens.xaml,
rather than inline properties on the elements: AGENTS requires shared styles, and one DataTrigger on
each element's own Tag serves both halves.
DesignTokensTests caught the one raw literal (#01000000) and it is now Brush.SegmentHitArea, which is the
rule working as intended.
680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
Creator ruling 2026-11-12, the parts that were unambiguous:
- The back chevron is gone from the left of the output line.
- "Output" gains a colon and takes the same 11 px muted style as "One output at a time".
- "press <esc> to return" sits centred under the switch pair.
- Esc actually returns from the Output world (MainWindow_PreviewKeyDown -> EnterBuildWorldCommand).
Without this the bar would have lost its only route back, since the chevron was that route.
UiShellTests guards all four: the colon, the hint, the absence of the chevron tooltip, and the muted
style class of both labels.
680 pass, 0 warnings. The switches' post-click behaviour is still awaiting the creator's mock
description, so this commit deliberately leaves the pill pair alone.
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
Creator ruling 2026-11-12: the action is not part of the output row, and it is not to be placed at all
until they say where. So Start Recording / Private Test Stream / Review & Go Live are out of the bar,
along with the divider that had been separating them.
What the middle of the bar is now: the world segment in Build, and the output line
(Output [YouTube] [Local MP4] One output at a time) in the Record / Go Live world. The view-model
commands (StartRecordingCommand, TestStreamCommand, GoLiveCommand) stay, unused by XAML, because they are
wherever the action lands next.
UiShellTests now asserts the bar contains none of the three action labels, so re-adding one by accident
fails the gate.
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
Creator correction: choosing Record / Go Live must take the [ Build Scenes ][ Record / Go Live ] dual
button away and put the Output line in its place. It was rendering both, segment first, which read as one
long toolbar instead of two worlds.
The segment is now Build-world only. That also retires the padlock glyph added in 6c5cd2f — with the
segment gone in the Output world there is no state left for it to describe, so the branch is removed
rather than left unreachable.
Because the segment was the only route back to the layers, the Output world gains a left chevron
("Back to Build Scenes"), present for the whole world including while a session runs — otherwise
editing during a show (unit 1) would have had no entry point and the bar would have dead-ended.
680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
Unit C (creator ruling 2026-11-12).
- Local MP4: one button, "Start Recording".
- YouTube: "Private Test Stream" beside "Review & Go Live", the latter with the primary face because it
is the default ask.
- Running: "End Live Stream" (or "End Test" for a test run, "Stop Recording" for a local one).
The behavioural change underneath: SessionStartDecision.For no longer takes `editing`. Since 2026-10-30
EDIT silently chose test-vs-real, which meant the same button did two different things depending on a
toggle three inches away. With Test as its own button that inference has no job, so For() now reads only
the armed output and ON-AIR always means the real livestream. That supersedes the 2026-10-30 ruling;
SessionStartDecisionTests is rewritten to pin the new matrix rather than the old one.
A signed-out press still starts the sign-in flow instead of sitting dead (no-dead-ends), unchanged from
before — now stated in one place, the two new commands, instead of inside a route table.
680 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
Unit B (creator ruling 2026-11-12). Pressing Record / Go Live now changes the bar: the REC/ON-AIR pill
pair becomes two labelled switches with glyphs (the YouTube mark, the record dot), under an "Output"
label and beside "One output at a time". Build world shows nothing but its own segment.
Two things this exposed that were not cosmetic:
- **Local MP4 was not actually the default.** Both pills started unarmed, so the stage opened with no
output selected; the old screen got away with it because the primary button inferred "record" from
"nothing armed". RecordPillOn now starts true, which is what "pos = ON (default)" means in code.
- **The stage gates the whole idle cluster, not just the switch.** Gating only the switch left the start
button visible in the Build world — a Start with no output chosen. ShowOutputStage (output world, idle,
not recording) wraps the cluster, so a running session hides the stage too: its output cannot change.
Exclusivity stays in the view model (arming one drops the other); the XAML merely reflects it.
TopBarWorldTests pins the stage's visibility and the radio pair starting at Local MP4.
680 pass, 0 warnings. Next: unit C (output-dependent primary action, End labels).
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement art,
still awaiting their visual check.
Creator ruling 2026-11-12: an icon left of each label, and Build Scenes' glyph becomes a padlock while
Record / Go Live is the active world.
- Build Scenes: a four-pane layout glyph while it is the active world, a padlock while the output world
is — the door is still there, it is just behind the session. Clicking it swaps the glyph back and
takes the creator into the layers, which is the only route back mid-show now that EDIT is gone.
- Record / Go Live: the ring-and-dot record glyph, the same "armed" language the REC sign speaks, so
the halves are read apart at a glance.
Both glyphs fill from the button's own Foreground, so the selected (red) and unselected states keep
legible ink without a second colour pair. UiShellTests guards both paths and the padlock's world flag.
679 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
Unit A of the top-bar redesign (creator ruling 2026-11-12). The standalone EDIT toggle is gone;
Build Scenes IS edit mode, so "which world am I in?" and "can I edit?" cannot disagree any more.
BarWorld (ViewModels/MainViewModel.TopBar.cs) has two values, Build and Output. Build ships
highlighted at launch and sets EditingActive on; choosing Record / Go Live turns it off. Clicking the
already-highlighted half is deliberately not a no-op: it re-enters that world, which is the only way
back to the layers during a session now that the EDIT button is gone (unit 1 made that legal).
WorldSegmentButton (Themes/Controls.xaml) is a Button, not a ToggleButton, and each half passes its
own flag through Tag to one shared DataTrigger — a ToggleButton would fight the view model and, with a
one-way IsChecked, clear its own binding on first click.
UiShellTests updated where it guarded the old EDIT markup (it now guards the segment). TopBarWorldTests
pins launch state, both transitions, and the re-enter case.
679 pass, 0 warnings. Next: unit B (output switch), unit C (output-dependent action + End labels).
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement
art, still awaiting their visual check.
Creator ruling 2026-11-12: "we don't care if a creator wants to edit their scene: move stuff around,
shrink/grow a webcam view, etc. Why not?" Correct — the app was only ever *forcing* EDIT off at
go-live, never enforcing it, so the gun-safety was a habit rather than a rule.
App.LockEditWhileLive ships off: a show no longer takes EDIT away. Turning it on restores the old
behaviour exactly — go-live flips EDIT off and stop flips it back, and only because the lock took it,
so a creator who had already turned EDIT off does not get it handed back on stop.
MainViewModel.ApplyEditLockForSession holds the rule so it is testable without starting a broadcast;
EditWhileLiveTests covers both settings, the round trip, and the "don't hand it back" case. Settings →
Editing carries the checkbox.
This is the first slice of TASK 68 (editing while live).
678 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
Two gaps in what 7f89534 shipped, both caught by the creator's report that sound still
leads the lips:
- The mic delay had presets only (off / 150 / 200), so the value a slower camera needs —
roughly 175 ms of residual on top of the 175 ms already applied — could not be set at
all. Now a 0..500 ms slider with off / 175 / 350 presets.
- Nothing recorded which number was in force. The setting lives in the layout DB, so a
preset click left no trace in the log; "Audio live: started" now names it.
Confirmed meanwhile from the profile: Audio.MicSyncOffsetMs is 175, so the delay is being
applied — the 150 ms of digital silence at the head of the 12:22 track is the delay line
priming, which is what an applied delay looks like. The residual offset is the camera's
pipeline being slower than 175 ms, not a missing delay.
674 pass, 0 warnings.
The webcam's frames reach the compositor later than the mic's samples reach the audio
pipe, so the voice runs ahead of the lips. The existing lip-sync offset could not fix
that: it delays the WHOLE mix, which drags desktop and alert audio — neither of which
has a camera to follow — out of sync in the process.
So the delay line learned channels (mono for a source, stereo for the mix, offset always
in milliseconds so the frame count is what stays constant), and FillAndMix runs a second
line over micChunk before the mix. Slider-side: presets off / 150 / 200 in the LIP-SYNC
bay under the whole-mix control, persisted as Audio.MicSyncOffsetMs.
Default 175 ms is the creator's own number for a C920 pipeline (they asked for
+150..+200). It is a setting, not a constant, because the right figure belongs to the
camera and the machine — a faster camera wants less, no camera wants none.
Both delay lines are reconfigured every tick, so the 2026-09-12 trap still applies and is
documented at both sites: a Configure that reallocates discards the buffered voice every
10 ms.
673 pass, 0 warnings. Ear check pending: this is an audio change.
Add a persisted global App Settings switch. Off keeps the existing per-severity
lifetimes (errors remain sticky); on makes every alert non-expiring and closes
it when its body is clicked. The NotificationService supplies the current
setting to each toast request.
StickyNotificationIntegrationTests covers persistence and both request modes.
618 tests green, clean build with 0 warnings.
Reference: Notification.Wpf exposes a CloseOnClick request option:
https://github.com/G-Stas/Notification.Wpf
App Settings now chooses one of six ToastArea positions (left/right ×
top/center/bottom), defaulting to the existing bottom-right. The setting is
persisted per profile and the toast area moves immediately; edge gutters keep
bottom notifications above the footer.
ToastPositionIntegrationTests covers live repositioning and restart persistence.
617 tests green, clean build with 0 warnings.
Reference scan: OBS Notifier supports user-configured notification placement:
https://github.com/DmitriySalnikov/OBSNotifier
App Settings can now restore only the staged canonical scene. The VM saves
pending edits first, resets that scene, reloads immediately, and keeps the same
scene staged. Other scene edits survive; the action is disabled while live or
recording. 615 tests green, clean build with 0 warnings.
Reference: OBS Scene Collections keep scene/source sets distinct:
https://obsproject.com/kb/scene-collections
App Settings now offers a clearly described all-scenes reset. It is disabled
while live or recording and reloads the restored layout immediately. The
integration test drives the real VM command and checks the Settings binding.
614 tests green, clean build with 0 warnings.
Reference: OBS Scene Collections are a distinct saved set of scenes and sources:
https://obsproject.com/kb/scene-collections
608 green, 0 warnings. Completes the layer half of TASK 65.
- **(+) → Replay Clip** adds an ordinary media layer with an app-owned path, one per scene, silent by
default (`MediaMuted`) and looping only outside Live — the compositor needs no special case.
- **F10 saves → the Live layer plays it once, then hides** (`SessionCompleted` → invisible, guard
cleared). Every other scene with a replay layer keeps looping the kept set, advancing on each clip's
completion, wrapping at the end.
- **The guard the creator asked for:** while that Live replay is playing, **F10 is inert** with a plain
explanation. That is what replaces the impossible "exclude the layer from its own clip" — with one
encoder and two muxes the clip *is* the program, so the fix is to not clip a clip.
`ReplayLayerTests` drives the real VM: one layer per scene, muted by default, once-through in Live,
looping in BRB, and a stray F10 with clips off writes nothing.
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).
591 green, 0 warnings.
- `Scene.SceneMutesMic` / `SceneMutesDesktop` are **nullable booleans**: null = leave the mix alone, so
a scene is passive until the creator says otherwise. Schema columns added, NULL for every existing
layout except one deliberate default.
- **Applied once when the scene takes the PROGRAM output** (`TransitionToScene`, only when live or
recording). Staging a scene offline is auditioning, not a stream edit — the test asserts that.
- **The creator is boss:** a manual mute survives any transition into a scene with no opinion; a scene
that does have one wins on its next entry. `SetMicMuted`/`SetGameMuted` are idempotent so a repeated
"mute" cannot clobber the level that unmute would restore.
- **BRB ships muted** (creator spec) for new layouts *and* via a one-time migration for existing rows
where the creator has never expressed an opinion.
- UI: two three-state checkboxes under the layers list ("When this scene goes live"), styled with
`PropertyPillCheck` so the theme test holds.
`SceneAudioTests` covers the whole rule in one integration test: default state, offline staging does
nothing, live entry mutes, manual unmute survives a passive scene, an opinionated scene wins, desktop
untouched, and all three states roundtrip through save/reload.
Needs the creator's eye: the checkboxes are new chrome in the left panel (indeterminate = passive).
YouTube moves the goalposts, so the app stops modelling them as its own truth and shows progress against
a threshold table that carries the date it was read.
- `Models/YppProgress.cs` — pure: three metrics, per-criterion met, and `MeetsPublishedRule`, which
encodes the real shape of the rule (subscribers AND either view route). The drawer says "x of 3 criteria
met — YouTube reviews eligibility" and spells out the either/or, so "2 of 3" can't be misread.
- `Services/YouTubeAnalyticsService.cs` — the two numbers only Analytics publishes: long-form watch hours
(trailing 12 months, `creatorContentType==video`) and Shorts-feed views (90 days, `==shorts`). No rows
means NO DATA, not zero.
- **Thresholds are data with a publication date** (`YppThresholds.AsOfUtc`, shown in the drawer) and a
test fails once that reading is more than 180 days old. The 2027-02-01 doubling (4,000h → 8,000h,
10M → 20M Shorts views) resolves from the date, not from an edit.
- **No Analytics scope → no invented numbers:** the drawer explains the missing consent instead of drawing
two empty bars as zero. Money stays behind 3·20's "Show monetary metrics" toggle.
- Fan-funding tier (500 subs) demoted to a single muted line, since the three bars are the ad-revenue path.
Tests: `YppProgressTests` — either/or eligibility, subscribers alone insufficient, Shorts as a qualifying
route, bars clamp at 100%, the doubling date, the staleness guard, Analytics parsing (no rows = no data),
and compact value formatting. FAST gate: 567 passed, 0 failed, 0 warnings.
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.
The last resilience slice. When a drop ends the broadcast, YouTube cannot resume it, so the only way
back is a new one — and that is what the app now offers, in one click.
- `Services/BroadcastTitle.cs` — the resume marker is applied to the title the show was using, and any
existing marker is REPLACED rather than stacked ("Show (resumed)" → "Show (resumed 2)"), with the base
truncated so the whole title fits YouTube's 100-character limit. Otherwise a channel grows two
identically named videos and the creator has to explain it.
- `MainViewModel.BackOnAirCommand` — reuses the ordinary go-live path with the saved details and the
cached stream: new broadcast, new VOD, no dialog. It appears only after a drop ended the session and
the ordinary start button steps aside, so there is exactly one way forward.
- The running line goes red OFF AIR (not amber) in that state; the button reads "● Back on air".
Tests: `BackOnAirTests` — first re-air marks the title, a second drop counts instead of stacking, the
100-character limit holds, an empty title still works, and the post-drop state exposes exactly one
start affordance. FAST gate: 552 passed, 0 failed, 0 warnings.
TASK 32 is now complete: fragmented MP4, blip retry, poll-driven RECONNECTING, ingest pre-flight, Back
on air.
Creator challenge (2026-11-05): "I really don't think that's going to be a constant — it's going to
depend on so many variables, some that are dark to us." Correct, so the measured-grace constant and
the countdown are dropped from the spec. YouTube's cutoff is server-side and depends on ingest health,
prior stream quality, region and load; a pinned number would be a guess wearing a timer, and it would
be wrong exactly when the creator is staring at it.
What ships instead:
- **Amber RECONNECTING** in the top bar with measured facts only: elapsed seconds and the attempt
number ("0:12 · attempt 2 of 4"). The seconds come from a stopwatch started at the drop.
- **The status poll speeds up while recovering** (30 s → 4 s) and drives the sign; it restores 30 s
when recovery ends.
- **`chatEndedEvent`** — already in the chat feed we poll and previously skipped — now raises a signal
that prompts an immediate status check. It never decides anything: `liveBroadcasts.list`
`lifeCycleStatus` stays authoritative, with `liveStreams.list` health as the push-received signal.
- At `complete`: red OFF AIR (the existing stop path) rather than a pretend-live sign.
Tests: `ChatEndedSignalTests` (the marker is not a chat row, the service raises the signal, and a
complete broadcast ends recovery with the honest message). FAST gate: 543 passed, 0 warnings.
Derived from the clock rather than a literal, so a build shipped next year does not display a stale
date. The About test asserts the rendered string, year included.
Creator: system messages are limited to two lines; the label above the message ('STEAM ID NOT
PRESENT', 'STREAM') was repeating what the row already said and pushing the stack past its budget.
AttentionAlert loses Label; rows render Text only. The licence row is now exactly:
Steam ID Limit: 30fps
Click for More Info
(60fps with a close control once owned). Rows are identified by Id internally, so nothing in the UI
depends on the removed text. Tests updated to assert position/text rather than the heading.
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.
Creator design (2026-11-05). A stack of red rows at the top of the left panel, above both mode grids
so it shows while editing and while live, pushing the panel's content down.
- Row 1 is the **Steam licence indicator** and nothing displaces it. It reads as status, not a
warning: "Entitlement 30 fps · Maximum 60 fps", both numbers derived from QualityOptions and
HasProOutput. While higher tiers are unavailable it shows a **pin** (standing indicator, no close
control); once ownership is verified the pin becomes a **close control** and the dismissal is
persisted (`Steam.LicenceIndicatorDismissed`), permanent while ownership holds. Losing ownership
restores it as pinned — a change of state, not a preference. Click → About.
- **Event rows stack beneath, newest first**, one row per episode (an episode advancing its state
updates its own row rather than adding another). Each is dismissible. Click → the Logs tab opens
with that event's sequence range painted red (`HighlightedLogFrom/To` + `LogInRangeConverter`; two
numbers rather than a collection because the log list is virtualised and realised rows do not
re-evaluate on membership changes).
- Replaces the old red panel under the quality picker, which is removed.
- The Steam diagnostic text loses "Contact the creator or support" for a neutral
"Steam licensing is not configured in this build."
Tests: `AttentionAlertStackTests` — pinned first with the status text; owned → closable and the close
survives a reload but ownership loss restores it pinned; pinned cannot be dismissed; event rows LIFO
with in-place episode updates; clicking an event opens Logs and sets its range. AboutHub + Entitlement
tests updated for the removed panel and the new wording. FAST gate: 532 passed, 0 failed, 0 warnings.
The previous commit inserted the round-media MultiDataTrigger INSIDE a <Setter> instead of into the
Style.Triggers collection, so every window that loads PreviewPane threw
"System.Windows.MultiDataTrigger is not of type System.Windows.SetterBase" at parse time. A Debug
build would have failed to open the main window at all.
Caught by `AlertBoxPreviewVisibilityTests` and `AboutHubTests` in the suite, which is the reason
those UI tests exist. Both green again, as is `MediaPreviewDisplayTests`.
Two follow-ups from the creator's lip-sync observation.
1. Audio still slept a fixed 50 ms per chunk — the same "sleep on top of the work" fault fixed for
video an hour ago, left in place. Every chunk's read cost was added, so a clip's audio fell
progressively behind its picture: small per chunk, unbounded over a long clip. It now uses the
same deadline pacer, so audio and video advance on the same wall clock.
2. The preview pane is NOT the compositor. "Preview looks great" proves the decode and the preview
bitmap; the recording and stream are blitted by SceneCompositor. `MediaCompositeOutputTests`
renders a keyed video layer over a background through the real compositor and asserts the two
things that matter: keyed (alpha 0) pixels let the background through, and the subject half
covers it. So "green screen removed" is now proven in the output path, not just the pane.
Also: a round-clipped video layer had no preview trigger (only webcam+Round existed), so it would
render in the output and be invisible in the pane. Same unit-circle path now covers it.
Tests: 16 scoped (pacer, real-clip rate + keying, composite output, decode pacing, alert pacing)
green. FULL SUITE NOT RUN — the machine is in use: two runs under load aborted in the WPF test host
(HWND subclass crash) and one hit the 15-minute cap. Re-run scripts/verify.sh in a quiet window.
Three reports from the creator, plus two bugs the real clip exposed.
1. A video layer was INVISIBLE in the preview. The pane's per-layer Image is Collapsed by
default and switched on by a whitelist of DataTriggers; MediaSource matched none, so the clip
decoded into a bitmap that nothing displayed. Same defect the alert box hit on 2026-09-26 and
the scoreboard after it — now recorded at the top of docs/ai/ui-surface.md with a test that
asserts the bound Image is Visible (`MediaPreviewDisplayTests`).
2. Play is no longer tied to Loop: the button is always enabled (creator ruling — Loop must not
toggle Play). Loop still restarts a finished clip, which is the behaviour that was broken.
3. Geometry is now the LAST section of the properties list for every draggable element, with
opacity at the top; the web source's duplicate geometry block is gone (it now uses the shared
one, which had been sitting above the media panel).
4. The physical mouse-drag test is deleted. It injected real cursor movement, hijacked whatever
machine ran it, and failed whenever the desktop was busy. The arithmetic it covered now lives
in `Helpers/DragReorderMath.cs` — pure, and covered by `DragReorderMathTests` (threshold,
up/down moves, no-op and out-of-range). The two persistence tests were already synthetic.
5. Real-clip check (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080): the detector
REJECTED it — a fully saturated green sits at value 1.0 and the detector's 0.98 brightness
ceiling threw it away. White highlights are excluded by the saturation floor, which is the
property that actually separates them, so the ceiling is gone. With it fixed, the clip is
detected as green (high confidence), and keyed through our own pipe 97.8% of pixels go
transparent. Measured: keying with the canonical #00B140 keys 0.0% of this clip — which is why
the key colour comes from the measured screen.
Tests: 567 passed, 0 failed, clean build 0 warnings. The gate's scope check flags
`Assets/Videos/test_greenscreen_moving_subject.mp4` — the creator's working copy, deliberately not
committed; the fixture lives under ytLive.Tests/Fixtures.
Reported 2026-11-05: "when I click Loop, the video doesn't play in the preview pane". Two
causes, both real:
- `VideoClipPipe.StartAsync` bailed out once it had run once (`_run != null`), so a clip that
reached EOF could never be started again — it held its last frame forever.
- Loop is only consulted at end-of-file by a LIVE decoder, so flipping it on a finished clip
did nothing at all.
Now: the pipe reports running state and is restartable after a natural end; the manager marks a
session finished (keeping its last frame) and exposes `IsRunning`/`RestartAsync`; the layer shows
`IsMediaRunning`/`IsMediaFinished`; flipping Loop on a finished clip restarts it.
UI: `▶ Play` sits to the right of the **Video File** label and is enabled only while Loop is off
(creator ruling: with Loop on the clip already runs continuously, so there is nothing to start).
The panel says "Clip finished — holding the last frame." so a frozen picture explains itself.
Also: verify.sh no longer runs physical-input tests by default. That test moves the real cursor
(SetCursorPos + mouse_event); running it mid-gate hijacks the machine someone is using, which is
exactly how it felt. Opt in with PHYSICAL_TESTS=1 when the desktop is idle; the header documents
the scoped-run command for working hours.
Test: `MediaPlaybackRestartTests` (pipe restart, finished state, Play again, Loop-on-finished).
8 scoped tests green. FULL SUITE NOT RUN — the machine was in use; run scripts/verify.sh in a
quiet window before the next milestone.
TASK 64 slice F — the two things left after the feature worked by hand.
- App Settings → **Video clips → Auto Green/Blue Screen** (off by default). When on, a
high-confidence detected screen is keyed as soon as the clip is added; the panel says
"Removed automatically" so an automatic change is never silent. Medium confidence still
asks, low says nothing — a wrong automatic key eats the picture.
- **A clip is a clip:** the alert box's own replacement clip keys through the same settings
block (one alert box per layout, so its settings are the ones the next alert uses), and
choosing an alert clip runs the same detector. The Green/Blue Screen panel now appears for
a video layer *or* an alert box with a custom clip (`Source.ShowsChromaKey`).
- Spill ships as the creator-facing **Fringe** switch; nothing further to tune behind it.
Test: `ChromaKeyApplyTests` gains the auto-apply case (no click, still explicit in the panel)
and an alert-clip case (its pipe asks ffmpeg for colorkey+despill with the blue screen type).
Docs: TASK 64 complete in TASKS.md, known limits recorded in the task file, HANDOFF rewritten.
verify.sh: 546 passed, 0 failed, 0 warnings.
TASK 64 slices D and E: the feature is usable end to end.
- Add a video file → one frame is probed (new `IChromaProbe`/`FfmpegChromaProbe`, one ffmpeg
pass at 160x90, off the UI thread) and the layer gets an offer: "Looks like a green screen —
Remove it", with the measured colour as a swatch.
- Accepting it turns the offer into settings (Amount / Softness / Fringe) that reach ffmpeg as
`colorkey` + `despill` + `format=bgra`, so the key is computed in the pipe and both consumers
(the shared preview bitmap and the compositor's frame) carry the same alpha.
- Media sessions are now keyed by **path + look** (`MainViewModel.MediaSessionKey`), so two
layers with the same settings still share one decoder, and changing the look releases the old
session and starts the new one. Removal, load reconciliation, loop and audio settings all move
to the same key.
- Settings persist on the `Source` row (six ALTER TABLE guards + SELECT/INSERT), so a keyed layer
comes back keyed.
- Panel section is plain creator words: Green/Blue Screen, Amount, Softness, Fringe.
Test: `ChromaKeyApplyTests` — the pipe asks ffmpeg to key (and does not when off); a green-screen
clip is offered, accepting it starts a keyed session, the layer's resolver then reads that session
rather than the old one; the setting survives a save.
Docs: TASK 64 D/E ticked with slice F (auto-apply setting + alert clips) left open; frame-pipeline
records the decode-option design; HANDOFF rewritten. verify.sh: 544 passed, 0 failed, 0 warnings.
A video file was picture-only: the decode pipe passed `-an`, so a starting-soon
or BRB clip with music played silent everywhere. Adds sound to every clip and
collapses the two decoders into one.
- `Services/VideoClipPipe.cs`: the shared pipe behind `MediaVideoSource` (TASK 21)
and `AlertClipDecoder` (TASK 47) — video (`rawvideo bgra`, scaled) + audio
(`f32le -ac 2 -ar 48000`, 50 ms chunks, byte-carry across reads), both paced to
real time, loop restarts both so picture and sound stay together. Alert
behaviour is unchanged.
- `MediaVideoSourceManager` applies the layer's Level/Mute per session and raises
`AudioAvailable`; `MainViewModel` feeds it to the new
`AudioMixer.EnqueueDesktopMediaAudio`, which joins the DESKTOP/GAME ring so the
desktop fader and the mic auto-ducker apply exactly as they do for game audio
and TRAX (creator ruling 2026-11-05: audio joins the group).
- Per-layer `MediaMuted` + existing `MediaVolume` surfaced as Mute/Level in the
properties panel; `MediaMuted` persisted (ALTER TABLE guard + SELECT/INSERT).
- Level/mute are per SESSION because one decoder is shared per file path — the
last layer touched wins; one decoder per layer would play the file twice.
Test: `MediaSourceAudioTests` — the pipe emits 50 ms chunks at 48k stereo, the
manager scales by level and drops when muted, and a media layer's sound moves the
desktop meter. Alert/decode suites unchanged and green.
Docs: task-21 closed on item 10, the "silent soundtrack" facts corrected in
frame-pipeline/output/overlays, HANDOFF rewritten. verify.sh green (529 passed;
the two documented pre-existing failures only).
TASK 21's picker slice: the decoder, manager, pacing and loop mechanism were
already shipped but nothing ever acquired a session, so a media Source rendered
nothing. Adds "Video File" to the (+) menu (video-filtered OpenFileDialog),
one decode session per distinct path with a HasSession guard, release when the
last scene reference goes, and a post-load reconcile. Loop is a manager-level
per-path answer ("any live reference loops") applied before StartAsync and
updated live via SetLooping. Properties panel shows the file with Change… and
Loop. Volume stays unwired: media audio rides the desktop loopback, TRAX-style.
Reference: OBS's media source (file → decode → composite into the scene) is the
established answer for this feature; the lifecycle shape mirrors this repo's
WebView2/ScreenCapture registration rather than inventing a new one.
Test: ytLive.Tests/MediaSourceAcquisitionTests.cs — add starts one decoder and
frames reach the layer, loop flip reaches the decoder, remove stops it.
Docs: task-21 closed, TASKS row + Day-4 row + stale 3·17 row corrected,
frame-pipeline acquisition note, HANDOFF rewritten. verify.sh green.
Put size and color on one row, place emphasis labels before their toggles, and split the remaining font families into handwriting, symbols, and international groups while omitting legacy dingbat fonts. Text-layer properties follow the existing hierarchical layout and palette conventions.
Use category-filtered font and selected-item dropdowns, separate horizontal and vertical alignment, and reuse the border-color palette templates for text color. Persist vertical alignment additively. Follow the established static text-source pattern: https://obsproject.com/kb/text-sources
Raster static text on UI-thread changes and reuse the same transparent BGRA frame in preview and all compositor paths. Cache text frame identity in the frame-pump signature and invalidate static bakes on edits. Follow OBS text-source conventions: https://obsproject.com/kb/text-sources
TextOverlay layers now have a multiline editor, Windows font and size controls, color/emphasis/alignment settings, and a visible draggable preview. Text sources follow OBS's static text-source conventions: https://obsproject.com/kb/text-sources
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
Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging.
Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473
Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only.
Ref: https://obsproject.com/kb/obs-studio-overview
Keep non-owner resolution choices visible but muted. Selecting one preserves the free tier and opens About; external navigation is only the explicit llamacasty.com link. Ref: https://streamlabs.com/desktop
The earlier pass checked wiring only; this is the typography sweep. Raw Foreground/FontSize
labels are gone from the elements panel:
- Opacity, Timer (minutes) and URI are headings above their control (PropertyHeader), with the
control row on the panel's 10px indent — the same shape as Border Thickness/Opacity.
- Scroll speed, scoreboard Opacity/Text size and their value readouts are PropertyFieldLabel.
- The drag hint and the scoreboard sign-in note are PropertyHint.
- Simulate (dev) keeps its orange, gains the field-label typography.
- Edge, Text colour and Background were PropertySubHeader/SubHeader: ONE heading rank now, so
PropertySubHeader and the legacy SubHeader are deleted from Themes/Controls.xaml.
- DeveloperPanel — the last panel with raw typography and an unstyled CheckBox — now uses the
same styles, and PropertiesStyleTests accepts PropertyPillCheck (the guard went stale when
every check box became a pill switch). 18 tests pass; Release 0 warnings.