From 8292663791fb608f3c20b4dcb010ee2003698e83 Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 13 Aug 2026 15:56:29 -0700 Subject: [PATCH] =?UTF-8?q?TASK=204=20ship=20step=206:=20health=20stats=20?= =?UTF-8?q?land=20in=20the=20bottom=20bar=20=E2=80=94=20FramePump.HealthUp?= =?UTF-8?q?dated=20is=20now=20wired=20to=20the=20VM=20(OnFramePumpHealthUp?= =?UTF-8?q?dated=20marshals=20onto=20the=20UI=20thread=20=E2=80=94=20the?= =?UTF-8?q?=20encoder's=20stderr=20loop=20raises=20on=20a=20background=20t?= =?UTF-8?q?hread=20=E2=80=94=20and=20copies=20the=20parsed=20bitrate/FPS/d?= =?UTF-8?q?ropped/duration=20into=20CurrentHealth,=20the=20bottom=20bar's?= =?UTF-8?q?=20existing=20binding);=20ResetHealth=20zeroes=20dropped=20fram?= =?UTF-8?q?es=20+=20elapsed=20duration=20on=20go-live=20and=20on=20End=20s?= =?UTF-8?q?o=20stats=20never=20linger=20from=20a=20previous=20session=20(b?= =?UTF-8?q?itrate/FPS=20stay=20on=20the=20tier's=20targets);=20the=20bar?= =?UTF-8?q?=20shows=20real=20encoder=20values=20once=20TASK=205=20fills=20?= =?UTF-8?q?=5FrtmpUrlProvider=20(until=20then=20the=20pump=20skips=20the?= =?UTF-8?q?=20encoder)=20=E2=80=94=20169=20tests=20passing,=200=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 57 ++++++++++++++++++------------------- TASKS.md | 2 +- ViewModels/MainViewModel.cs | 39 +++++++++++++++++++++++++ ViewModels/index.md | 2 +- ai.md | 12 ++++++-- 5 files changed, 77 insertions(+), 35 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index b2a032a..8e9b271 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,36 +7,32 @@ ## Session state (last updated: 2026-08-13) -- **Branch:** `main`. The audio UX follow-up (`9f9ed34`) and round 2 (`18abe42`) - are **committed and pushed**. -- **This session (round 3 — mic mute icon + REC sign):** - 1. **Mic mute icon:** the mic bar gained a **microphone glyph** (same 16px style - as the speaker, white → red + slash when muted, `ToolTip=MicMuteText`) - **between the meter and the speaker** — both mutes adjacent with spacing - between the icons; click reuses the same `MicSpeaker_MouseLeftButtonUp` - handler → `ToggleMicMuteCommand`. No VM/code-behind change. - 2. **REC sign (top center):** the old LIVE badge (hidden when offline) became an - **always-visible REC chip** — dark gray dot (#555) + dim "REC" (#888) offline, - **bright red (#e94560)** + "REC" + elapsed while live, **darker red (#8f1f1f)** - when live with a **private** stream. Driven by new VM props - `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate` (notified from the - `StreamStatus` and `StreamVisibility` setters), privacy read off the **dialog's - chosen visibility** (`StreamVisibility == "Private"`) — the stream service still - hardcodes `privacyStatus = "public"`, so the private-REC state only lights once - that's honored (TASK 4 ship step 7). The dot pulses while live; `LiveIndicatorVisible` - and `LivePulseOpacity` were removed. - 3. **Voice filters queued as task 22** — bass boost, treble, noise suppression, - compressor on the mic input channel, KISS, always on (before the meter/encoder mix). +- **Branch:** `main`. The audio UX follow-up (`9f9ed34`), round 2 (`18abe42`), + and round 3 (`dc29adf`) are **committed and pushed**. +- **This session (TASK 4 ship step 6 — health stats in the bottom bar):** + 1. **Wiring:** `MainViewModel.OnFramePumpHealthUpdated` subscribes to + `FramePump.HealthUpdated` and marshals onto the UI thread (the encoder's + stderr loop raises on a background thread — same pattern as the audio + level handlers), copying the parsed bitrate/FPS/dropped/duration into + `CurrentHealth`, which the bottom bar's left stats group already binds + (bitrate/FPS/dropped/duration/health-message). + 2. **Session hygiene:** `ResetHealth(status)` zeroes dropped frames + the + elapsed duration (and clears `LastError`) on go-live and on End so stats + never linger from a previous stream; bitrate/FPS stay on the tier's + targets from `ApplyStreamQuality`. + 3. **Reality:** the bar lights up with REAL encoder values once TASK 5 fills + `_rtmpUrlProvider` with the reusable stream's ingest URL — until then the + pump skips the encoder (logged) and the bar shows the tier's targets. + No new tests needed: `FramePumpTests.HealthUpdated_ForwardsEncoderHealth` + already covers the pump→event seam; the VM handler is a thin marshal+copy. - **Mic status contract (creator's rule, verified — do NOT "fix"):** the status dot is **red until a mic resource is actually connected**. `MicStatus` starts `NotConnected` (red); it goes green ONLY when the mixer's `MicConnected` fires, which comes strictly from the mic source's `Started` event raised after `StartRecording()` succeeds. Zero devices at startup → stays red and the mixer is never started; capture failure → yellow. Green must never be raised earlier (e.g. on loopback start or on `Start()` being called). -- **Uncommitted:** this round-3 work — `ViewModels/MainViewModel.cs` (REC props, - `_recDotPulse`, StreamVisibility/StreamStatus notifications, `LiveIndicatorVisible`/ - `LivePulseOpacity` removed), `MainWindow.xaml` (REC chip + mic mute icon), - `TASKS.md` (task 22 + round-3 audio note), `ai.md`, `ViewModels/index.md`, `HANDOFF`. +- **Uncommitted:** this session — `ViewModels/MainViewModel.cs` (health wiring + + `ResetHealth`), `TASKS.md` (ship step 6 ✅), `ai.md`, `ViewModels/index.md`, `HANDOFF`. - **Verified:** pending (build + full test run right before commit). - **Landmines:** - Never set a local `Canvas.SetTop` on the social bar — a local value permanently @@ -64,12 +60,13 @@ integration test (a real `MainWindow`), which never goes live — keep it that way. - Sandbox can't reach outbound HTTPS — `HttpSocialValidator` stub-handler tests only, never the real instance. -- **Next step:** commit + push round 3 (one commit). Then TASK 4 ship step 6 — health - stats: bind `FramePump.HealthUpdated` (bitrate/FPS/duration) into the bottom bar. - Also queued: task 21 (logo + About hub), task 22 (voice filters). Nothing else - queued — do not expand the task queue on your own. Optional, not queued: - rewriting the healed entry's `ProfileUrl` to - `https://mastodon.llamachile.tube/@gramps` (user must say the word). +- **Next step:** commit + push this ship-step-6 work (one commit). Then the remaining + TASK 4 requirement is **ship step 7 — one-click go live + private-only enforcement** + (honor the dialog's chosen visibility / enforce `privacyStatus = "private"`; also + fixes the REC sign's private state — see round-3 note above). Also queued: task 21 + (logo + About hub), task 22 (voice filters). Nothing else queued — do not expand + the task queue on your own. Optional, not queued: rewriting the healed entry's + `ProfileUrl` to `https://mastodon.llamachile.tube/@gramps` (user must say the word). - **Secret/DB/port facts live:** OAuth client id/secret in `Helpers/OAuthCredentials.cs`; OAuth session token in `Helpers/TokenStore.cs` (DPAPI → `%APPDATA%\ytLlive\ytLlive.auth`); diff --git a/TASKS.md b/TASKS.md index 019195b..2e284a5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -204,7 +204,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four 3. ✅ **Encoder + RTMP push SHIPPED** (2026-08-12) — the FFmpeg subprocess: raw BGRA frames via stdin, stderr health parsing, FLV mux + push to the ingestion URL (see the ship step 3 plan below) 4. ✅ **WASAPI audio capture SHIPPED** (2026-08-12) — NAudio loopback (desktop/game) + the picked mic feeding `AudioLevel`, so the realtime meter comes alive (see the ship step 4 plan below) 5. ✅ **Frame-pipeline wiring SHIPPED** (2026-08-12) — `CameraManager`/`ScreenCaptureManager` → compositor resolver → encoder, driven by a paced `FramePump` (see the ship step 5 plan below) -6. ☐ **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (req 5) +6. ✅ **Health stats SHIPPED** (2026-08-13) — `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration, already forwarded from `FfmpegEncoder.OnStderrLine`) now lands in the bottom bar: `MainViewModel.OnFramePumpHealthUpdated` marshals to the UI thread (the stderr loop raises on a background thread) and copies into `CurrentHealth` (the bottom bar's existing binding); `ResetHealth` zeroes dropped/duration on go-live and on End so stats never linger from a previous session (bitrate/FPS stay on the tier's targets). The bar lights up with real values once TASK 5 supplies the RTMP URL (until then the pump skips the encoder and the bar shows the tier's targets) 7. ☐ **One-click go live + private-only enforcement** — Go Live always creates/updates the broadcast with `privacyStatus = "private"` + PRIVATE badge (req 8, test-verifiable) The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 8da6769..28707ce 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -1078,6 +1078,7 @@ public class MainViewModel : ViewModelBase log: message => AppLog.Write(message), socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom)); _framePump.Failed += OnFramePumpFailed; + _framePump.HealthUpdated += OnFramePumpHealthUpdated; LoadLayout(); _ = LoadSavedSessionAsync(); @@ -1965,6 +1966,7 @@ public class MainViewModel : ViewModelBase ? "ytLlive" : $"{dialog.StreamTitle} — ytLlive"; StreamStatus = StreamStatus.Streaming; + ResetHealth(StreamStatus.Streaming); _ = _framePump.StartAsync(); // never throws; failures log + surface via Failed } } @@ -1973,6 +1975,7 @@ public class MainViewModel : ViewModelBase { StreamStatus = StreamStatus.Offline; WindowTitle = "ytLlive"; + ResetHealth(StreamStatus.Offline); // Audio capture is always-on (preview monitoring); only the frame pump // and the session stop here. _ = _framePump.StopAsync(); @@ -2114,6 +2117,42 @@ public class MainViewModel : ViewModelBase if (IsLive) StreamStatus = StreamStatus.Error; } + // TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/ + // duration) lands in the bottom bar. The stderr loop raises on a background + // thread — marshal to the UI thread like the audio level handlers. + private void OnFramePumpHealthUpdated(object? sender, StreamHealth health) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null && !dispatcher.CheckAccess()) + dispatcher.BeginInvoke(() => ApplyHealth(health)); + else + ApplyHealth(health); + } + + private void ApplyHealth(StreamHealth health) + { + CurrentHealth.Status = health.Status; + CurrentHealth.CurrentBitrate = health.CurrentBitrate; + CurrentHealth.FPS = health.FPS; + CurrentHealth.DroppedFrames = health.DroppedFrames; + CurrentHealth.StreamDuration = health.StreamDuration; + CurrentHealth.LastError = health.LastError; + CurrentHealth.HealthMessage = health.HealthMessage; + OnPropertyChanged(nameof(CurrentHealth)); + } + + // Keeps the bottom-bar stats honest across sessions: dropped frames and the + // elapsed duration must not linger from a previous go-live (bitrate/FPS stay + // on the tier's targets — ApplyStreamQuality sets them on pick). + private void ResetHealth(StreamStatus status) + { + CurrentHealth.Status = status; + CurrentHealth.DroppedFrames = 0; + CurrentHealth.StreamDuration = TimeSpan.Zero; + CurrentHealth.LastError = null; + OnPropertyChanged(nameof(CurrentHealth)); + } + private void UpdateLiveVisuals() { var live = IsLive; diff --git a/ViewModels/index.md b/ViewModels/index.md index 162ea9d..093c84a 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -4,7 +4,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions. | File | Purpose | |------|---------| -| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale so real speech/game levels actually occupy the bar), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar): `IsGameAudioBarVisible` (shown only while a full-screen game is producing sound — the VM polls `IGameAudioDetector` via the default `GameAudioDetector` every 250ms (`_gameAudioTimer`); the pure `GameAudioHysteresis` SHOWs after ~500ms of fullscreen+sound, HIDEs ~1s after leaving fullscreen, and **silence never hides an active bar**), `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand` | +| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Connected account:** top-bar avatar/name (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`, cleared on End). **Audio (KISS — the mic is the creator's only audio control; capture SHIPPED, runs for the app's lifetime so the meters preview live):** the sound meter is a **READ-ONLY realtime level display**: fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)` (`MeterFillWidth`/`MeterBrush`, green→yellow→red, zone markers at 60%/80% — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS scale so real speech/game levels actually occupy the bar), where `AudioLevel` (live input, 0 with no input) is fed by the audio mixer once capture lands and `MicVolume` acts as a gain on ambient noise; while the slider is dragged the bar previews the slider position (`SetVolumeAdjusting`), returning to the live level on release (0 with no input — clicking the meter does nothing); `MicVolume` (default 0.8) + read-only `MicMuted`/`MicMuteText`/`ToggleMicMuteCommand` — **MicVolume drives MicMuted** (muted ⇔ volume 0): sliding to 0 flips the speaker to muted, sliding up from 0 clears it; muting stores the prior volume, unmuting restores it (default 0.8 if unknown) and flashes the meter to the restored position ~300ms (`BeginVolumeFlash`/`EndVolumeFlash`); `MicSourceName` = picked voice source, shown left-justified inside the meter bar (the fill runs at 75% opacity so the text + ruler markings show through); `OpenMicPickerCommand`/`PickMicrophone()` open the `MicPickerDialog` (a picked device takes effect immediately — `PickMicrophone` swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running); the **MIC label is a button** (`OpenMicPickerCommand`) with a **status dot** (`MicStatus`, `Models/MicStatus`: green = `MicConnected` via the source's `Started` event, yellow = `MicFailed` — in use/unplugged, red = no mic device at startup; `MicStatusBrush`/`MicStatusToolTip`); capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live (`BeginGoLive`/`StopStream` no longer touch the mixer) — zero devices = red dot + the mixer never starts. **Game audio bar** (desktop/game, **overlaid at the bottom of the preview window** — bottom-center chip, a mirror of the mic bar): `IsGameAudioBarVisible` (shown only while a full-screen game is producing sound — the VM polls `IGameAudioDetector` via the default `GameAudioDetector` every 250ms (`_gameAudioTimer`); the pure `GameAudioHysteresis` SHOWs after ~500ms of fullscreen+sound, HIDEs ~1s after leaving fullscreen, and **silence never hides an active bar**), `GameAudioLevel` (loopback meter via `LoopbackLevelChanged`, scaled by volume), `GameMuted`/`GameMuteText`/`ToggleGameMuteCommand`, `GameAudioVolume` (0..1 volume slider), `Begin/End/CancelGameVolumeFlash` (mirrors the mic bar's volume flash); the game speaker + slider share the mic bar's `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` code-behind pattern. **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`). **REC sign (2026-08-13):** top-center indicator always visible — `RecDotBrush` (offline `#555555`, live `#e94560`, live-private `#8f1f1f`), `RecTextBrush` (dim offline, white live), `RecDotOpacity` (0.55 offline, pulsing 1.0/0.35 live via `_recDotPulse` flipped on the live tick), `IsLivePrivate` (`IsLive && StreamVisibility == "Private"`) — notified from the `StreamStatus` + `StreamVisibility` setters; `LiveIndicatorVisible`/`LivePulseOpacity` removed. **Mic mute icon:** a second 16px clickable glyph (mic, red + slash when muted) between the meter and the speaker on the mic bar — same `ToggleMicMuteCommand`. **Health stats (TASK 4 ship step 6, 2026-08-13):** `OnFramePumpHealthUpdated` marshals `FramePump.HealthUpdated` (encoder's parsed bitrate/FPS/dropped/duration — raised on the stderr thread) onto the UI thread into `CurrentHealth` (bottom bar bindings); `ResetHealth(status)` zeroes dropped/duration on go-live/End so stats never linger | | `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests | | `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel | | `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` | diff --git a/ai.md b/ai.md index 8e05fb4..131a185 100644 --- a/ai.md +++ b/ai.md @@ -402,7 +402,7 @@ devices, no timers). **Deferred:** the loopback→encoder AAC mix wiring (a later step — the encoder construction + frame pipeline shipped in ship step 5). -### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, plan in TASKS.md) +### Live frame pipeline (TASK 4 ship step 5 — shipped 2026-08-12, health stats 2026-08-13, plan in TASKS.md) The **`FramePump`** (`Services/Encoder/`) is the live frame producer: while live it snapshots the active scene each tick, resolves every element to its latest frame, composites it into the tier's output frame, @@ -417,8 +417,14 @@ seam:** `Func`, `Func` resolver, `Func