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).
Slice 3 of TASK 65. 606 green, 0 warnings.
`ClipLibrary` is the whole of clip "management": a file lands in `clips\` beside the recordings, the
oldest is deleted past the retention count, and `Recent()` returns newest-first because that is also the
replay layer's playlist order. No gallery, no prompt, no naming.
Two guards the tests pin: an **empty snapshot never becomes a zero-byte file** (a save that fails loudly
is better than a file that won't open), and **pruning only touches our own prefix** — another file in the
folder survives.
`RingBytesFor(seconds, bitrate)` is the memory cap the ring is sized with: exact wire size × 1.25
headroom, because bitrate moves and the ring trims whole fragments.
Still to come: the replay layer, the F10 hotkey, and the Settings switch.
Slice 2 of TASK 65. 602 green, 0 warnings. **Clips are still off**: no `ClipBuffer` is passed, so the
ffmpeg command line is byte-for-byte what it was before this commit, and a test asserts exactly that
(the original args are a strict prefix of the new ones).
- `FfmpegArgs` appends a fragmented-MP4 output to `pipe:1` only when `ClipBuffer` is set. One-second
fragments (`-frag_duration 1000000`) rather than the 4 s GOP, so a clip can start inside the window
instead of at the last keyframe. Stats already own stderr, so stdout was free.
- `Fmp4FragmentSplitter` turns that byte stream into whole fragments: `ftyp`+`moov` is the init
segment, each `moof`+`mdat` is a fragment. It parses box headers itself, survives a box being cut
anywhere across reads, and **resyncs instead of throwing** — a clip fault must never take the stream
down.
- `FfmpegEncoder` reads it on its own task, never blocking the frame path. Any exception logs and
disables clips for the session; the broadcast is untouched.
- `IEncoderProcess` gained `StandardOutputBytes` (the seam already had the text reader for probing).
Next slices: the replay layer + retention, then the Settings switch with disabled-but-remembered controls.
Slice 1 of TASK 65, pure memory, no encoder change yet.
A 1080p BGRA frame is 8.3 MB, so fifteen seconds of raw composite is ~7.4 GB — the 15 MB figure in the
spec is the *compressed* stream. So the ring holds fragmented-MP4 bytes that ffmpeg will write to a
pipe, and a save is init-segment + surviving fragments in order.
Two rules the tests pin: the cap is by **bytes** (bitrate moves; seconds do not), and the newest
fragments always win, because a clip that ends at the keypress is the entire point. The init segment does
not rotate and cannot be reused across encoder starts, so `Clear` drops it too — an MP4 without its moov
is noise.
Next slice: ffmpeg writes the buffer output to stdout while `-stats` stays on stderr, and the pump feeds
this ring. Not done here on purpose — an encoder change is the riskiest edit in the app and deserves its
own commit and test.
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.
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).
Last slice. 590 green, 0 warnings.
- **Shed before you charge the viewer.** On machine evidence the policy's first act is
`ShedCaptureCadence`: `ScreenCaptureFrameSource` widens its conversion interval 10 ms → 33 ms (≈100
→ 30 conversions/s) through a new `ScreenCaptureManager.ShedLoad`. The output tier, the declared fps
and the viewer's picture are untouched — we stop paying for readbacks we were throwing away. It
costs no step budget; if the starvation persists, the fps ladder runs on the next clean window.
- The shed is reset on every stop path, so a cheap session cannot leave a later one capped.
- **The session line is a Logs-tab line, not a session-report line.** The session report is the money
summary (Super Chats, per currency); quality chatter there would bury the numbers it exists for. The
cause-tagged line already lands in the log at the moment of the step.
TASK 33 is complete: two ladders, budgets, floors, ambiguity, one-way with recovery behind a flag, the
relaunch, the copy, and the cost shed. 18 tests pin the ladders, the copy and the restart.
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.
Slice 2. 588 green, 0 warnings.
- `FramePumpStats` and `FramePump.StatsReported`: the 5 s window the log line already describes, as
data. `Dropped` is the ENCODER's counter (frames submitted, not accepted); `Stalls`/`AvgRenderMs`
are the pump's. `FramesRendered` is deliberately not used for classification — a static scene
reuses the paste cache and renders few frames while completely healthy, so it would read as
starvation.
- `StepDownPlanner`: one per broadcast, owns the stateful policy, converts stats → sample, exposes the
landed tier for the restart. It never acts — the VM owns the effects, which is what keeps the ladder
testable without an encoder.
- `Describe` / `ReportLine`: the sentences a creator sees. Machine cause says LlamaCasty couldn't keep
up and the connection was not the problem; upload cause blames the upload and promises no further
thrash. A test asserts the machine copy never leaks "dropped" — mechanism strings stay in the log.
Remaining for the task: VM subscription, the encoder restart at the landed tier, the toast, and
shedding our own capture cost before stepping fps.
Pure policy, no wiring yet. 12 unit tests, 584 green overall, 0 warnings.
- **Machine starvation** buys less work: 60 → 30 fps first, then 1080 → 720. The vertical tier has no
resolution step — its floor *is* its only geometry.
- **Upload congestion** buys fewer bits: `current − max(0.5, current × lossFraction)`, floored at
3.0 Mbps (16:9) / 4.0 (vertical). Resolution is deliberately not a bandwidth lever: 1080p30 and
720p60 cost the same, so dropping pixels at an unchanged bitrate relieves nothing.
- **Budgets** as ruled: three steps for a 1920-wide start, one for 720 and vertical. 15 s of evidence,
30 s cooldown, ambiguous evidence does nothing at all.
- **Recovery** is `allowClimb`, default off (the one-way ruling): the machine carries its ceiling from
construction, climbs one step after 180 s of clean windows, and a climb followed by a relapse inside
120 s freezes the session at the low value — one flap pair is proof enough.
Inputs are the poster's own counters (`PumpStalls`, `PumpDrops`, `AvgRenderMs`, `EncoderDrops`), not a
throughput reading: `StreamHealth.CurrentBitrate` is ffmpeg's *encoded output* rate and sits at target
even while the socket is gagging, so it cannot size a step.
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.
The creator's profile still showed the previous Starting art after the replacement, with
`Art.DefaultVersion = 4` recorded — so the version gate had already declared itself done. It
compared a constant against the embedded resource, and those two were compiled at different
moments (version 4 landed with the OLD image embedded; the corrected image reached the assembly
one build later). A version bump can always be raced that way.
The seed now compares BYTES: `LayoutStore.AssetMatches` hashes the bundled resource and checks the
row the background already points at. Same bytes → leave it untouched (so this is idempotent and
never churns the database). Different bytes → re-stamp. The version constant and its setting are
gone, because a heal that only runs once can silently skip the one launch it was needed for.
`SceneThumbnailTests` planted its own art on a background still flagged as default art — in the
real app that state only ever means stale bundled art, because Browse… sets the path and clears
the flag. The fixture now models a creator's own picture.
Tests: 572 green, 0 warnings. Verified the profile's Starting asset hash (167CDD3B…, the
two-category render) differs from the shipped `starting-background.jpg` (5FC6DD22…), so the next
launch re-seeds it.
The creator's corrected Starting artwork (the in-app onboarding screen: sign in → stream details →
go live) replaces yesterday's version. 2724x1536 resampled to the 1920x1080 master, q3 (432 KB).
The previous Gemini render is archived beside the original art.
Also fixes the two reasons the replacement would not have appeared on an existing profile:
- **The first heal iterated `AllBackgrounds()`**, which returns only the LIVE scene's capture rows, so it
cleared nothing and then saved the version anyway — permanently skipping the heal. It now walks every
scene's background. Version bumped to 4 so profiles stamped 2 or 3 heal.
- **The pack URI was unqualified**, so it resolved against the ENTRY assembly: LlamaCasty.exe in
production, the test host under `dotnet test`, where the seed silently did nothing. It is now
`/LlamaCasty;component/Assets/…`, which works in both.
Test: `BackgroundDefaultArtTests` seeds a profile, plants an "old art" asset on the Starting background,
marks the layout as an older art vintage, relaunches and asserts the shipped art is re-stamped. 45
background tests pass.
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.
Reported 2026-11-05: those two tabs stayed open when clicking elsewhere, unlike Stream Settings, YPP
and TEST. All five drawers share one host (TextPullOutHost), but the window's click-outside guard only
listed the other three, so the gear and socials panels had no way to dismiss except their own tab.
The guard now closes them too, and a test pins both directions: an outside click retracts the drawer,
and a click inside it does not.
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.
"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.
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.
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.
Reported as "the default video and the green screen video are playing at a hideously slow
rate… I thought the playback timing issues were resolved?!" They were not: two independent
faults, both in the shared clip pipe.
1. QUADRATIC COPY IN THE FRAME READER. `RawVideoFrameReader` re-concatenated the whole pending
buffer on every `Feed`, and the pipe feeds it 64 KB at a time. A 1080p BGRA frame is 8 MB, so
one frame cost ~127 copies of a growing buffer — roughly half a gigabyte of memcpy per frame.
Measured on a 30 fps clip: 6 frames in 3 seconds (2 fps). The reader now fills one preallocated
frame buffer and hands that array to the VideoFrame, so a frame is copied exactly once. Pipe
reads are 256 KB. Same clip: 29 fps.
2. PACING SLEPT ON TOP OF THE WORK. A fixed 1/fps sleep after each frame adds the frame's own
cost, so 20 ms of work on a 30 fps clip yields 1/(33+20) ≈ 19 fps — measured 20.9. Pacing is
now deadline-based (`Helpers/FramePacer.cs`): wait only what is left of the frame's interval,
resync when a frame overruns instead of bursting, never sleep longer than one interval.
The alert clip shares this pipe, so it gained both fixes.
Tests: `FramePacerTests` (subtract work, no drift, late → resync, zero duration, reset);
`ChromaKeyRealClipTests.RealClip_PlaysAtItsNativeRate` (throughput floor of 15 fps on the real
30 fps clip — the old code sat at 2); the two pacing assertions now check "at most one interval"
rather than an exact sleep, which is what deadline pacing means. Full suite: 574 passed,
0 failed, clean build.
The creator's test_greenscreen_moving_subject.mp4 (1920x1080, 5s, subject moving over a
green field) is what exposed the detector's brightness ceiling and proved the key colour must
come from the measured screen.
Development-only by construction, not by convention: ytLive.csproj lists Assets as explicit
<Resource Include> entries with no Assets/** glob, so a file that is not on that list is never
compiled in, copied to output, or published. The test project links this one path into its own
Fixtures output, so the repo holds ONE copy (23 KB).
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 32·4. A plain MP4 keeps its index (moov) at the end, so a power cut or a killed
process left the entire recording unreadable even though every video byte was on disk.
The record block now carries
`-movflags +frag_keyframe+empty_moov+default_base_moof`: index first, a playable
fragment flushed at every keyframe, so an interrupted take plays to the last fragment.
Record block only — the FLV stream output has no index and never gets the flags.
Rename-on-stop is untouched.
Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` asserts the flags on the
record block and their absence from the streaming block.
Not yet proven on a real interrupted take (kill ffmpeg mid-recording, play the result);
the args test is by construction.
Docs: task-32 slice 4 ticked with that caveat, TASKS row, output.md record note, HANDOFF.
verify.sh: 550 passed (mouse-injection test excluded, desktop in use), 0 warnings.
TASK 49 is retired (creator ruling): YouTube owns chat moderation. Its blocked-words filter and
hold-for-review run upstream of `liveChatMessages.list` — held messages are never returned
(https://stackoverflow.com/questions/74511524/) — so a second local list would duplicate a switch
the creator already owns, with a second set of false positives and a second support surface.
What we actually owed the platform, and had wrong:
- The parser added every item to the overlay. A deleted message arrives as a `tombstone` carrying
no text at all ("it has since been deleted … only liveChatId, type and publishedAt are present" —
https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages), so every
deletion rendered as a BLANK chat row. `hasDisplayContent:false` messages and chat-mode events
did the same.
- Deletion events (`messageDeletedEvent`/`messageRetractedEvent`/`userBannedEvent`) are documented
but have not been delivered to clients for years (https://stackoverflow.com/questions/44346893/),
so nothing is built on them; they are skipped if they ever arrive.
- Found while testing: Super Chat read `displayString`, but YouTube's field is `amountDisplayString`
— every amount silently fell back to a recomposed string, which mangles non-dollar locales. The
model's own comment said this was the reason the field exists.
Test: `ChatModerationParseTests` — tombstone, no-display-content, chat-mode events all produce no
row; a real Super Chat still parses its pre-rendered amount (constraint 4: reward fields are never
reinterpreted).
Docs: TASK 49 marked retired with the citations, the rule recorded in decisions.md, HANDOFF updated.
verify.sh: 550 passed, 0 failed, 0 warnings.
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.
TASK 64 units B and C — the two pure halves, no wiring yet.
- `Services/ChromaKeyDetector.cs`: samples the frame's outer ring (the screen is at the
edges, the subject in the middle), reports Green/Blue/None plus the colour actually shot
and a confidence. Confidence is coverage × hue tightness, which is what keeps a lawn or
a football pitch from being offered as a keyable screen. Letterbox bars are excluded from
the denominator rather than counted against the screen. Never decides anything: an offer
is the creator's to accept.
- `Services/VideoFilterChain.cs`: options → `-vf` (scale → colorkey → despill → format=bgra)
plus a `Signature` so two layers keyed identically can share one decoder. Numbers clamp and
an unparseable colour falls back to the app default, so ffmpeg is never handed a filter it
rejects at spawn.
- Keying is done by the bundled ffmpeg, not by us: verified against the shipped binary that
`colorkey=…,format=bgra` emits straight-alpha BGRA (alpha 0 on keyed pixels), which is what
the compositor already blends. No per-pixel loop lands in the 16.67 ms render budget.
Test: `ChromaKeyDetectorTests` — green, blue, letterboxed, lawn (must say None), mixed scene,
dark frame, plus the chain strings and signature sharing.
Docs: TASK 64 queued in TASKS.md with its slice table; the decisions.md chroma-key reversal
recorded as a reversal (it was buried for webcam background removal, not for supplied clips).
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 additive defaults and round-trip tests for per-layer text content and formatting. Model the static text source after OBS text-source conventions: https://obsproject.com/kb/text-sources
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