Commit Graph
697 Commits
Author SHA1 Message Date
gramps 37ef74eb8d docs(handoff): bitrate diagnosis, four-unit plan, unit 1 landed
Records the 5.1-vs-8 finding as an args defect (no -rc, so NVENC ran VBR) rather
than a machine or ingest limit, the four units that follow, and two new landmines.

Assets/starting-background.jpg stays out of this commit: it is the creator's
replacement art, still awaiting the visual check recorded in the landmines.
2026-11-12 07:51:30 -08:00
gramps 1e3a4f7fe0 feat(entitlement): pre-release switch so Release builds can test 60 fps
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.
2026-11-12 07:50:59 -08:00
gramps 9c0c40c688 feat(settings): make slide-out alerts persist until dismissed
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
2026-11-11 10:19:20 -08:00
gramps ff5c005bd9 feat(settings): position slide-out notifications
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
2026-11-11 10:10:21 -08:00
gramps 2f5d0c1759 feat(settings): position slide-out notifications
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
2026-11-11 10:06:54 -08:00
gramps 9621db29c9 fix(stream): keep recovery notifications and stop on the dispatcher
The FFmpeg ProcessFailed event arrives off-thread. Start recovery on the UI
dispatcher, publish retry status there, and route StopStream back to the
dispatcher before changing bound state or alert collections. This prevents a
recovery exception from becoming an unhandled WPF CollectionView crash.

AttentionAlertStackTests covers worker-thread recovery reporting and stop.
616 tests green, clean build with 0 warnings.
2026-11-11 09:57:26 -08:00
gramps 24077e58cf fix(stepdown): restart the audio pipe around encoder changes
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.
2026-11-11 09:52:27 -08:00
gramps 2c5a3d8c11 docs(70): queue the Live Window App overlay layer
Record the creator's Live-only, single-layer scope and distinguish it from the
existing session-scoped Capture Window backdrop pin. Include HWND lifecycle,
protected-window, visual-only, and duplicate-underlay boundaries.

Reference scan: Microsoft HWND capture item API and OBS Window Capture:
https://learn.microsoft.com/en-us/windows/win32/api/windows.graphics.capture.interop/nf-windows-graphics-capture-interop-igraphicscaptureiteminterop-createforwindow
https://obsproject.com/kb/window-capture-sources
2026-11-11 07:42:37 -08:00
gramps 4e5b4e3999 docs(69): queue the chat sample preview toggle 2026-11-11 07:37:38 -08:00
gramps b265d42fb4 docs(handoff): record pushed main state 2026-11-11 07:31:19 -08:00
gramps 1d35a7400f docs(37): close defaults/current split and advance the queue 2026-11-10 18:10:35 -08:00
gramps e6c6913994 feat(37): expose the staged-scene factory reset
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
2026-11-10 18:09:16 -08:00
gramps 41064bdd13 feat(37): expose the all-scenes factory reset
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
2026-11-10 18:05:35 -08:00
gramps b8eb2b4451 feat(37): restore all scenes to factory defaults 2026-11-10 17:59:28 -08:00
gramps ff1c1965e0 fix(37): preserve camera choice when restoring default layouts
Build-mismatch restoration now keeps the currently selected webcam identity,
restores default scene placements against that camera, and leaves cameras absent
when none is selected. The integration regression test pins the creator's camera
choice and restored default geometry.
612 tests green, clean build with 0 warnings.
2026-11-10 17:56:24 -08:00
gramps c4da06b56b feat(37): reset one scene to its factory snapshot
The per-scene reset restores that scene's settings, layers, webcam placement,
and background-path settings while leaving other scenes' current edits alone.
A focused integration test covers the restore/isolation behavior.
611 tests green, clean build with 0 warnings.

Reference: OBS scene collections store scenes and sources as a saved set:
https://obsproject.com/kb/scene-collections
2026-11-10 17:52:20 -08:00
gramps 1c6fc3223d feat(37): restore the default layout when the build ID changes
A mismatched or missing current-build stamp restores the immutable default
snapshot atomically before the VM loads scenes. The new integration case edits
current on build A, opens on build B, and verifies the factory layout returns.
610 tests green, clean build with 0 warnings.

Reference: OBS scene collections keep scenes/sources as a separate saved set:
https://obsproject.com/kb/scene-collections
2026-11-10 17:48:43 -08:00
gramps 37791086ae feat(37): keep a default layout snapshot beside the current layout
The matching-build persistence slice is in place: existing layouts are captured
once as defaults, first-run factory scenes are saved and captured before the
creator can edit, and same-build startup continues to load current rows. Default
asset references survive current-layout saves.

One integration test covers the current-layout roundtrip and immutable default.
609 tests green, clean build with 0 warnings.

Reference scan: OBS stores scene collections separately from output profiles;
this split keeps layout state independent in the same SQLite store:
https://obsproject.com/kb/scene-collections
2026-11-10 17:45:39 -08:00
gramps b093609ad3 docs(14): close mic gain slice after creator test 2026-11-10 17:40:44 -08:00
gramps de787e0dad docs(65): replay clips complete; queue moves to 66 2026-11-10 12:55:06 -08:00
gramps 5fcfa545e5 feat(65): the replay layer plays — once in Live, looping everywhere else
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.
2026-11-10 12:54:54 -08:00
gramps a5c2061b55 feat(65): the replay layer exists in the model; the exclusion problem is solved by the guard
Slice 5a, scaffolding only. 607 green.

- `Source.IsReplayLayer` + schema column + roundtrip: a media layer that shows saved clips instead of a
  chosen file. The compositor needs to know nothing — it is a media layer whose path the VM owns.
- **Two spec corrections from the creator (2026-11-10), recorded in the task:**
  1. The immediate replay of a fresh clip happens **only in the Live scene**. While it plays, **F10 is
     inert** ("replay in progress").
  2. That guard replaces the "layer excluded from its own clip" idea, which is **impossible** with one
     encoder and two muxes: the clip is a second mux of the same frames, so it necessarily contains what
     viewers saw. No second encode, no fiction about a clean feed.

Remaining for 65: the add-layer menu entry, `ClipSaved` → play (Live: once then hide; elsewhere: loop the
kept set), `SessionCompleted` → advance or hide, and silent-by-default audio.
2026-11-10 12:48:41 -08:00
gramps 28657918f9 feat(65): F10 saves a clip; the gear drawer owns the three settings
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).
2026-11-10 12:41:39 -08:00
gramps f7d3d1301e feat(65): clips on disk — write one, keep 3/4/5, delete the oldest
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.
2026-11-10 12:35:38 -08:00
gramps 5066d81aab feat(65): the clip mux — a third output on the same ffmpeg, drained off the frame path
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.
2026-11-10 12:25:19 -08:00
gramps b93744ec7f feat(65): the replay ring — encoded fragments in RAM, capped by bytes
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.
2026-11-10 11:59:02 -08:00
gramps 1a6a549e17 feat(55): stop leaving disk debts — temp clips and a low-disk guard
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.
2026-11-10 11:55:42 -08:00
gramps c257de1516 feat(35): scenes remember the room — BRB mutes the mic on entry
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).
2026-11-10 11:51:21 -08:00
gramps 80ccdc9cb8 docs(readme): new hero screenshot (1400×862, 289 KB)
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.
2026-11-09 14:32:02 -08:00
gramps ec6a5ca466 docs(queue): six veggies, then the new work; scheduling and publishing retired
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.
2026-11-09 14:27:07 -08:00
gramps 42228c7aab docs(handoff): 40 lines 2026-11-09 13:21:14 -08:00
gramps 4b3d3f2645 docs(handoff): fit the 40-line budget 2026-11-09 13:05:59 -08:00
gramps 4855d5cf8d docs(handoff): TASK 33 complete; queue starts at 34 2026-11-09 13:05:53 -08:00
gramps 0fa3006da7 feat(33): shed our own capture cost first; the task is done
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.
2026-11-09 13:05:44 -08:00
gramps 134103c08b feat(33): the step-down acts — one encoder relaunch, one toast, same URL
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.
2026-11-09 12:50:53 -08:00
gramps b84802e657 docs(handoff): trim to budget; 588 tests 2026-11-09 11:54:15 -08:00
gramps b39439abdc docs: the vertical take is clean — 29.8 distinct webcam images/s
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.)
2026-11-09 11:53:58 -08:00
gramps 555ff5c0ac feat(33): structured pump stats + the session planner and its plain-words copy
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.
2026-11-09 10:06:36 -08:00
gramps bc62bd81ec feat(33): StepDownPolicy — two ladders, one budget, recovery behind a flag
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.
2026-11-09 09:30:28 -08:00
gramps 92d875c343 tools(stats): mine sessions from the logs, split by sink
`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.
2026-11-09 09:04:04 -08:00
gramps ed32151136 docs(33): cause-classified step-down, and a toast that owns the mistake
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.
2026-11-09 08:51:31 -08:00
gramps e53f9974a5 docs(tasks): TASK 32 is complete — Back on air shipped; the queue starts at 33 2026-11-08 12:22:35 -08:00
gramps 4a43b3e229 docs(videos): fill the gaps — chroma, video layers, live edit, hotkeys, resilience
Audited the shipped feature surface against the list. Seven subjects were missing, three of them
distinguishers:

- **Green/blue screen removal** — auto-detect + opt-in suggestion, and it keys alert clips too.
- **Video file layers** — clip decodes, audio joins the mixer, per-layer mute/level.
- **Edit the broadcast on air** — title/description/tags/visibility, same fields prefill Go Live.
- Global hotkeys (F1–F5 / F6 / F7 / F8 / F9, OS-level), layout autosave, record with no sign-in.
- Resilience as one subject: ingest pre-flight, reconnect, Back on air, crash-safe fragmented MP4.

Corrections: the transition row claimed Move, which is still queued — now says so. The after-surge
list gained Move and scene-linked audio (35) and dropped entries that have shipped (media file
picking) or that belong to ready rows (resilience). 32 ready subjects, 8 later.
2026-11-06 15:59:12 -08:00
gramps a8f27db4c2 docs(videos): the running list — capture window/desktop, zoom gesture, How to Blur as its own video 2026-11-06 15:57:10 -08:00
gramps b4e952fda9 fix(go-live): the Start Stream dialog re-reads Stream Info at open
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.
2026-11-06 12:56:05 -08:00
gramps bef818f4a1 art(starting): new Starting background — cleaner chat panels
Creator supplied a 2730x1536 render; resampled to the 1920x1080 master at q3 (392 KB, down from
432 KB). The previous render is archived as `Assets/archive/starting-background-v2-2026-11-06.jpg`
alongside the two before it.

No version bump needed any more: `StampDefaultBackgroundAsset` compares the stored asset's hash with
the bundled bytes, so an existing profile re-seeds on the next launch by itself.

The same image replaces the Starting shot in the site's scenes section (llamacasty.web, deployed
2026-11-06 12:39).

Background tests: 45 passed; the Debug assembly embeds the new bytes (391786) and no longer carries
the previous render.
2026-11-06 12:40:27 -08:00
gramps dbcb69278f fix(audio): a DEBUG build never shares the pipe name with Release
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.
2026-11-06 12:30:48 -08:00
gramps 30824c6d6e docs(handoff): byte-compare art seed, not a version stamp 2026-11-06 12:21:35 -08:00
gramps 70e2c48136 fix(art): re-seed a bundled background when the stored bytes differ
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.
2026-11-06 12:21:24 -08:00
gramps 3db964e5cf docs(handoff): Starting art vintage 4, the AllBackgrounds trap, site scenes shot 2026-11-06 12:06:13 -08:00