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.
Creator report (2026-11-12): "the creator can still use the layer icon — visibility — if it was
already enabled for that layer". Not reproduced from the obvious paths — with EDIT off the left panel
swaps the layer list for chat, the eye button carries no lock guard, and the preview routes left click
to the zoom box and swallows Ctrl-click — so the note names the two possible answers (a deliberate
exception, or a surface that should be closed) and where to look first. Parked, not acted on.
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement
art, still awaiting their visual check.
Shipping 175 ms as the built-in mic delay was wrong on principle, not just in value: it shifts
every creator's voice to correct one camera on one desk, and it hides whether a desync is theirs
or ours. A correction has to be something the creator turned on.
So DefaultMicSyncOffsetMs is 0. The control stays (slider 0..500, off/175/350 presets, applied value
logged) for anyone whose camera needs it; it is simply no longer pre-applied. The global offset was
already 0.
AudioSyncSettingsTests pins the rule: a fresh profile reads 0 for both, the mic delay refuses a
negative (advancing is the global offset's job) and clamps above 500, and set values round-trip.
676 pass, 0 warnings. Scope justification: Assets/starting-background.jpg is outside this unit — the
creator's replacement art, still awaiting their visual check.
Mic delay off, global offset -130 ms. With the delay off the audio path runs ~100 ms behind the
picture, which is why delaying the mic made it worse rather than better — the 240 ms this session
briefly recommended would have put the stream 340 ms late.
Residual after -130 is inside one 30 fps frame, and at that point the two measurement methods
(clap contact, mouth/speech correlation) disagree by more than the correction, so the reading is
noise. Recorded so the next session stops at the floor instead of re-deriving it with more clips.
Scope justification: Assets/starting-background.jpg is outside this unit — the creator's replacement
art, still awaiting their visual check.
Three numbers, from the recording itself: each stream's length, the drift implied by a length
mismatch (a clock problem), and the residual offset between mouth motion and speech (a constant
problem). Per-3s window table with correlations, so a weak reading announces itself instead of
reporting a confident wrong number, and a median over only the strongly-correlated windows — a
whole-clip search on periodic speech locks onto the wrong beat and returns nonsense (it said
-400 ms at the search limit on the clip where the windows said -185).
Validated on ty-20261112-1228-457-0000.mp4: lengths 13.271/13.305 s (match, so no drift) and
-185 ms (sound early), which agrees with the independent visual read of the same clip.
The creator is deaf, so "does it sound right" cannot be the loop. This is.
Needs numpy; uses the bundled ffmpeg.exe.
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.
Third measurement, and it names the start-up defect the creator heard as "started off
wrong, then corrected" (ty-20261112-1222-748-0000-2.mp4):
wall clock (audio start -> audio stop) 12.614 s
video: 754 frames 12.588 s correct
audio: 585 packets 12.477 s 137 ms short
pipe connected 160 ms after the session opened
first 150 ms of the audio track digital silence (the mic delay line's head)
The rate is fixed — 12.5 s of show, no accumulating drift — but starting the clock at
connect made the audio stream begin 160 ms after the video stream, so every word sat 160 ms
late and the track came out that much shorter. The silence at the head is the 175 ms mic
delay doing its job, not a fault.
Now the clock runs from session open. Time that passes before ffmpeg connects stays owed,
and it is paid the moment the pipe connects — out of the capture ring, so it is the audio
that was missed rather than padding. Debt larger than one 200 ms chunk continues to be paid
over the following ticks.
674 pass, 0 warnings.
Second measurement of the same recording family (ty-20261112-1133-701-0000-2.mp4):
wall clock (audio start -> audio stop) 6.477 s
video: 386 frames 6.455 s correct
audio: 294 packets 6.252 s 226 ms SHORT
Same magnitude as the original bug, opposite sign — the clock fixed the tick-rate error and
exposed the next one underneath. Audio started 49 ms before the pump, ffmpeg connected ~80 ms
after, and every chunk written in that window was dropped (dropped writes 27). The loop still
counted those frames as produced, so the track lost exactly that much time.
Now: the clock does not start until the pipe is connected, and AudioClock.Advance counts a
frame only when its write reached the pipe. A dropped write stays owed and is paid off on the
next tick, so a start-up gap costs content (silence, which there was anyway) instead of
length.
Two measurements, two different signs, one lesson: the audio track's LENGTH has to be the
clock's, and anything that removes samples without removing time — a fast tick, a dropped
write — shows up as drift no static offset can fix.
674 tests (one notification-timing flake under full-suite load; passes alone), 0 warnings.
Measured on the creator's recording (ty-20261112-1118-423-0000-2.mp4), which drifts
out of sync over ten seconds:
wall clock (pump start -> audio stop) 9.875 s
video: 594 frames, uniform 1/60 9.888 s correct
audio: 485,376 samples @ 48 kHz 10.112 s +2.1%, 224 ms long
So it was not VFR and not wall-clock PTS: the video is CFR and matches the clock (the
only PTS anomaly in the file is one 38 ms gap from two lost frames). The audio track is
what was too long, and a static offset cannot fix a rate error — which is why the opening
looked aligned (the 175 ms mic delay cancels the camera's constant pipeline latency) and
the end did not.
Cause: LiveLoopAsync wrote a fixed 480 frames per iteration and trusted that each
iteration was 10 ms. Under a raised timer resolution they land near 9.8 ms — 102 ticks
per second where 100 are expected, i.e. 102% of an audio second per wall second. FFmpeg
stamps audio by sample count, so every second of show pushed the sound ~2% later against
a correct picture. Video never had the bug because the pump paces off a stopwatch.
Fix: Services/Audio/AudioClock asks the clock how many frames are owed
(expected - produced, clamped to one 200 ms chunk) and the loop produces exactly that,
padding on underrun. A stalled loop pays its debt over the following ticks instead of
dumping one huge write. FillAndMix takes a frame count, and the master limiter/meter take
spans, so a partial chunk is processed without touching stale buffer tail.
674 tests (one log-rotation flake under full-suite load while the app itself is running
and writing startup.log; passes alone), 0 warnings. Ear check pending.
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.
Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps
spent — and Models/RateHistory turns that into at most a suggestion. The app never
applies one: it asks before the next stream, "Use it" sets the rate, "Keep mine"
remembers that exact number so the same question does not return until the evidence
moves. Nothing leaves the machine.
Two rules keep it from nagging. The high-water mark is the highest rate that
*finished* a session with zero steps, not the highest seen, so one easy session (a
menu screen, a quiet game) cannot set a target the machine cannot hold. And it is
asymmetric: raising needs 10% above the current rate across five clean sessions of the
same tier and encoder, lowering needs three step-downs in five — fixing damage should
not wait for a percentage, and risking it should.
The encoder key is real, not decorative: StreamHealth.VideoEncoder is now filled from
the probe (FfmpegEncoder), so history recorded against NVENC is not applied to a
machine that later runs QSV. At startup, before anything has streamed, the last
session on that tier is the honest guess.
672 pass, 0 warnings.
The tier table cannot know a creator's connection, so it should not pretend to. The
Mbps box under the picker is theirs: the tier chooses geometry and frame rate, the
box chooses bits. Stored per tier (`Video.Rate.<tier>`), so tuning 1080p60 does not
touch 720p30, and none means "the shipped default" rather than zero.
Clamped at the tier's YouTube minimum (`RateLadder`), because a target under it
cannot hold the resolution; no ceiling otherwise, because a wrong guess is exactly
what live step-down exists to catch, and a creator who types 25 on a fibre line
should be allowed to find out. The tooltip carries YouTube's number for the tier.
`MainViewModel.EffectiveQuality` is the single substitution point, and the encoder
options, the step-down policy and the clip buffer all read it — so a rated tier can
never stream at the shipped default because one call site was missed.
One ordering trap found by the test: the constructor resolves the default tier
before the layout store is open, so a read at that point must not cache, or the
shipped default shadows the creator's stored rate for the whole run.
670 pass, 0 warnings.
Every 1080 tier carried 8 Mbps, inherited from TASK 4's "mainstream ceiling,
upload headroom stays comfortable". It was never derived from anything: the same
number served 30 fps and 60 fps, landscape and vertical, which no per-tier
derivation would produce. With CBR now actually holding the rate (700c7a8) that
number becomes the picture quality, so it has to be chosen rather than inherited.
New defaults sit between YouTube's H.264 recommendation (17 at 1080p60, 14 at
1080p30, 8 at 720p — support.google.com/youtube/answer/2853702) and the rate
practitioner OBS guides converge on for YouTube (9–12 Mbps at 1080p60). Vertical
mirrors its landscape pair: 1080x1920 is the same pixel count as 1920x1080.
The free-profile fallback in OutputProfile.Resolve moves with it (8.0 -> 10.0) so
a build with no shipped tiers still emits the 1080p30 rate rather than a stale
one.
668 tests (1 audio-pipe flake under full-suite load, passes alone), 0 warnings.
The stream was configured as "@8 Mbps" and averaged 5.1. The cause is the argument
set, not the machine and not YouTube: `-b:v K -maxrate K -bufsize 2K` is capped
VBR. Measured on the bundled BtbN build with a static 640x480 frame at -b:v 2000k:
default rate control 9,858 bytes / 8 s = 9 kbps
-rc cbr 2,001,989 bytes / 8 s = 1,951 kbps
-rc vbr 9,858 bytes / 8 s = 9 kbps
On high-motion testsrc2 the same target produced 2006 kbps (CBR) against 1741
(VBR): VBR undershoots by however easy the picture is, and a composited scene
with a still background is very easy.
Spelling is per encoder, verified rather than assumed:
- h264_nvenc, h264_amf: `-rc cbr`.
- h264_qsv: nothing. It has no `-rc`, and libavcodec/qsvenc.c (select_rc_mode,
FFmpeg d263fce2b2) selects MFX_RATECONTROL_CBR when bitrate == maxrate — which
the triad already sets. Passing -rc here would be an unknown option.
- libopenh264: nothing. `-rc_mode bitrate` changed no measurable output on this
build (3,832 vs 3,830 bytes), so it stays out rather than adding a line whose
effect nobody can demonstrate.
Reference: YouTube's encoder spec requires CBR
(support.google.com/youtube/answer/2853702).
668 tests pass, build 0 warnings.
The frame-rate boundary is correct as a product rule, but it only bites in a
Release build, where DevBuild.IsDevelopment is false and DeveloperMode cannot
unlock anything — exactly the build whose performance numbers are worth reading
(see docs/ai/environment.md: Debug pixel paths are several times slower). 60 fps
could not be tested at all.
OutputProfile.EntitlementEnforced is false: every shipped tier is selectable by
any build, and HasProOutput — the one property the tier setter, the dropdown's
strikethrough and the locked-item style all read — answers yes regardless of
ownership. static readonly rather than const, so the consumed `||` does not fold
into unreachable operand code (same reason as DevBuild.IsDevelopment).
The licence indicator row is suppressed while the switch is off: it exists to
explain what the boundary costs, and claiming "Steam ID Limit: 60fps" to an
unowned build would be a false statement. Docs corrected in place — the enforced
rule is unchanged, only its state is recorded.
Tests: one integration case covers both switch states (unowned build reaches
1080p60 with the switch off, 1080p30 with it on), so flipping at launch leaves no
red test behind. The two suites that anchor on the pinned licence row early-return
while it is absent; EntitlementTests covers that state meanwhile.
667 pass, build 0 warnings.
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
A quality step-down now closes the named audio pipe before stopping/flushing the
pump, then opens a fresh server before restarting the pump. This prevents ffmpeg
from waiting on the old audio input and avoids reconnecting to a broken pipe.
EncoderRestartTests pins the audio-stop → video-stop → audio-start → video-start
order. 616 tests green, clean build with 0 warnings.
Creator-supplied capture of the main window: five scenes across the top, the Starting scene with its
three callouts in the preview, the layer panel, and the audio dock.
Re-encoded with the bundled ffmpeg instead of the WPF resize tool (scale 1400 + q4) — one command, the
same tool the rest of the assets use — and that shortcut is recorded in MyMistakes next to the original
recipe so it is not re-derived a third time.
Creator ruling 2026-11-09: finish the queue before the new features, gold pass always last.
**Retired (platform's surface, not ours):**
- 34 scheduled streams — YouTube owns scheduling *and* the reminder that makes it worth anything.
- 50 publish recording — Studio owns upload (resumable, quota-aware, explains rejections), and an
in-app uploader would widen the OAuth consent to whole-channel video management for one convenience.
The app adds "Show in folder" instead.
**New tasks:** 65 replay clips (the creator-side buffer YouTube does not give you), 66 timed ticker
messages, 67 per-event alert videos, 68 edit-while-live + first-run On-Air.
**TASK 65 spec** is the interesting one: 15 s is a clip (10/15/20, default 15); the ring holds encrypted
*encoded* fragments in RAM — raw 1080p frames would be ~7.4 GB for 15 s — so there is no steady disk
write and no crash debris; F10 saves; the replay layer plays once then hides in Live and loops the last
1–3 elsewhere; silent by default; three kept; the layer is excluded from its own clip (hall of mirrors).
**TASK 14 gains Branch 3:** mic input gain ahead of the gate (table boom mics fall under it), webcam
blank hotkey, opacity/geometry parity with a collapsed Geometry disclosure, Show-in-folder.
**Decisions recorded:** the replay-buffer reversal (it was retired in 2022-era reasoning as "different
product"; the case for it won), the platform-surface rule, and voice triggering deferred — always-
listening in a streaming app hears the game, so the content is its own false-positive source.
Queue refactored to 56 lines by archiving completed rows and the superseded surge plan; HANDOFF at 40.
Creator's Release take `ty-20261109-1142-036-0005.mp4` (1080×1920 @ 30, 300 s) measured by cropping the
webcam tile and diffing frames: 29.8 distinct images/s overall, median 30 s window 30.0/s, worst 24/s,
one 4-frame repeat run in 9026 frames. The app's own lines agree (`webcam 28.4–30.0/s` against camera
`arrivals 29.9–30.1/s`).
- The 25.6/s failure recorded on the 2026-10-05 16:9 take does **not** reproduce in the vertical tier,
so the vertical take no longer blocks the cadence units.
- Recorded the measurement trap in `frame-pipeline.md`: a threshold of 1.0 on a downscaled tile
reports long "0.0/s" stretches during low motion — duplicates sit below 0.02, a still face at
0.3–0.7. Read the histogram before believing a count.
- Vertical-tier camera check reported 1–6 pass; both creator-hands items in TASKS.md are closed.
(The mic being off for the first 2:42 does not affect video cadence.)
`scripts/session-stats.py` turns every `startup*.log` into one row per FramePump session: tier,
scene, measured fps, drops, stalls, average/worst render, capture rate, and the build — with the
sink marked, because that is what makes the numbers quotable:
- **RECORD** — machine only, no socket. The cleanest minimum-config evidence.
- **TEST** — machine + socket, no audience; recognised by `Test session dock open` /
`Test chat simulation`, which the creator pointed out are recorded too.
- **LIVE** — network and audience included.
- **UNKNOWN** — no marker; not evidence.
Verdicts use the average render against the frame budget, not the worst: the worst is dominated by
the 100 ms first-frame bake (a session that "holds" can still show worst 35 ms). `holds` = no drops,
no stalls, average under half the budget.
First run over the existing logs already separates the two halves of TASK 33: static Starting scenes
render at ~1.5 ms, the same pump with display capture running at 35–46 ms — the capture path, not the
overlays, is where a budget streamer loses the frame budget.
Design settled with the creator. Two findings drove it:
- **Resolution is not a bandwidth lever.** 1080p30 and 720p60 are both 6 Mbps in the shipped table,
so "step down to 720p" relieves nothing against congestion; and 60→30 at the same bitrate relieves
nothing either. Congestion therefore steps **bitrate only** (8.0 → 6.4 → 5.1 → 4.1, ~20%), which is
what OBS Dynamic Bitrate does — cited, adopted, with its climb-back discarded for v1.
- **The other half has no OBS equivalent**: machine starvation (the game and the app on one PC) steps
fps, then resolution, and sheds LlamaCasty's own capture cost *first*. That is the app taking
responsibility for a stream it is helping to starve.
Budgets as ruled: 3 steps for a 1080 start, 1 for 720 and vertical, hard floors at 720p30 /
Vertical 1080p30, hold-and-warn at the floor, never disconnect. Trigger ≥5% drops over 15 s twice,
30 s cooldown, ambiguous evidence does nothing.
New rule in `ui-surface.md`: a toast names the cause, the culprit and the remedy. Mechanism strings
("All pipe instances are busy", HTTP codes, exit codes) are log findings, never user-facing copy.
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 promo read 'until April 1, 2027', which is a public promise tied to a review queue outside our
control: identity verification and the tax interview are in Valve's hands (they quote up to 30 business
days), and the store page and build reviews follow. If any of that slips, the date becomes untrue.
It now reads '30 days after the store page goes live — after that $99, locked for life', in the About
box and in commercial.md, so the claim cannot be wrong however long Valve takes.
Also recorded: the $100 Steam Direct fee is PAID (creator, 2026-11-05); a store page can be configured
once identity clears. The launch-price deadline no longer depends on Valve's calendar.
The docs described Steamworks onboarding as pending work. It is pending VALVE: the partner account
exists, identity verification is under review, and the tax interview is in their queue with a quoted
turnaround of up to 30 business days. No app IDs are assigned, no store page exists, and Release
entitlement therefore still fails closed — by design.
Distribution.md §2 becomes a status table plus the critical path computed from Valve's own published
windows (30 business days verification → fee/app IDs → up to 12 business days store review → page
public ≥2 weeks before release → up to 12 business days build review). Earliest realistic launch:
early-to-mid February 2027, leaving ~7 weeks before the 2027-04-01 launch price ends.
Nothing in this blocks the code; commercial.md, TASKS 54 and HANDOFF now say so instead of implying
Steam is an outstanding coding task.
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.
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.
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.
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 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.
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
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
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