The cadence work ended with a question the log could not answer: the compositor
manages 6.8 composites/s at ~141ms while the 10:03 take did 29-34ms on the "same
scene shape" (full-render split=0 elements=6 dynamic=5). Nothing in the stats line
said WHICH of the six elements cost what, and nothing recorded the scene itself, so
the two takes could only be compared by eye. This closes that gap and nothing else:
no fix is attempted until a real take names the culprit.
- Services/Compositor/CompositeTimings.cs (new): per-window collector.
Add(stage, ticks) accumulates; Describe(renders, minMs = 0.1) prints the averaged
breakdown slowest-first, dropping sub-0.1ms noise.
- SceneCompositor.Render / CompositeLayers / BakeStaticBase / BlitBakedElement /
BlitCachedLayer + SceneGraph.GetBakedBase: trailing optional CompositeTimings?
Every stage is timed (backdrop, base, flash, social, ticker, zoom, scale, overlay,
bake backdrop, bake overlays) and EVERY element gets its own entry keyed by
z-index + name ("#2 Webcam"), with the nested "raster" sub-stage recording the
paste-cache rasterize miss - the split between "re-samples every tick" and "slow
paste" without a second session. Bake-pass elements are prefixed "bake #..." so a
one-off rebake cannot masquerade as a per-composite cost. Callers that pass
nothing (thumbnails, existing tests) are unaffected and pay nothing.
- FramePump: owns the collector, threads it through RenderScene/RenderFull/
GetBakedBase/CompositeLayers, and logs "FramePump parts (N renders/5s): ..." once
per 5s window beside the stats line (averaged per render, so it compares directly
with "avg render"), then resets it. Also dumps the scene - "FramePump scene dump:"
header plus one line per element (z-index, name, kind, source type, visibility,
rect, opacity, clip) - at pump start and on every scene change.
- One Good Dog integration test (ytLive.Tests/CompositeTimingTests.cs): a real
composite of a full-canvas element plus a 64x64 one through the real collector
must rank the full-canvas element first, carry the raster sub-stage and the fixed
stages, and the dump must name both elements with z-index/type/rect and echo the
split point.
Derivative work (AGENTS.md): OBS's answer to "which source is slow" is the Source
Profiler - average execution time of each source's tick and render functions
(https://docs.obsproject.com/reference-libobs-util-source-profiler, util/profiler.h).
Same shape here: one timer per element per composite, reported as an average, plus
the stage split this case needs.
Docs in the same commit: ai.md (new subsection "Per-element composite timing + scene
dump", the closed gap corrected in "Frame cadence"), Services/index.md (new file row
+ FramePump/SceneCompositor rows), TASKS.md (unit marked shipped, next = creator
take), HANDOFF.md (rewritten: unpushed commit, next step, verify.sh whitelist hole).
Also recorded this session (from the creator): the launch command is
`dotnet run -c Release` - Release is the DEFAULT, Debug only when explicitly named,
and perf takes must read Release. ai.md -> Run updated.
Full suite 428 tests, 0 warnings; the only failures are the known audio env flakes
(both pass in isolation). Scope check passed.
28 KiB
28 KiB
Services — index
External-facing logic: YouTube API, persistence. See
schema.md for the memory-map conventions.
| File | Purpose |
|---|---|
YouTubeAuthService.cs |
OAuth2 via Google: loopback callback (http://localhost:8765/oauth2/callback), token exchange, refresh, channel fetch. Constructor takes optional HttpClient + sessionChanged callback (test seam + save hook); session persists via Helpers/TokenStore (DPAPI); ClearSession() signs out (called by MainViewModel.StopStream on End Livestream) |
YouTubeStreamService.cs |
Broadcast/stream management via the v3 API. CreateBroadcast always sends privacyStatus = "private" (ship step 7 — private-only until v1) and the one-click v3 flags (enableAutoStart/Stop, enableMonitorStream=false, latencyPreference=low); injectable HttpClient? http = null ctor seam so tests can fake the API. GetOrCreateReusableStreamAsync (TASK 9, shipped 2026-08-16) returns the channel's ReusableStream — lists liveStreams?mine=true and reuses the existing cdn.isReusable stream, inserting once only on first use (resolution=variable, frameRate=variable, isReusable=true); a stream id given to CreateBroadcast binds at insert via contentDetails.boundStreamId. RTMP URL = ingestionAddress + "/" + streamName. GetStreamHealthAsync(streamId) (TASK 9 item 3, shipped 2026-08-16) polls liveStreams?part=status → StreamHealth with parsed configurationIssues[] (NOTE 2026-09-22: the real API nests status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]} as an OBJECT — the parse reads the nested .status and tolerates the old flat-string shape; the object shape used to crash every poll) |
YouTubeStreamService.cs (2026-10-09 additions) |
GetBroadcastPrivacyAsync(id) (liveBroadcasts.list part=status → privacyStatus) and DeleteFinishedBroadcastAsync(id) (videos.delete, fallback liveBroadcasts.delete on 404; never throws) — the private-broadcast keep-or-delete close-out |
StreamHealthReporter.cs |
Pure report-by-exception decision (TASK 9 item 3): BannerFor(issues) → HealthIssueReport(Text?, IsError) — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
YouTubeChatService.cs |
Polls liveChat/messages, raises MessageReceived; IDisposable. TASK 43 rewrite (2026-09-24): re-arms its one-shot poll on the server's pollingIntervalMillis (streamList semantics — reconnect on that cadence with nextPageToken, clamp 1000–6000ms, maxResults=2000); ParsePage (internal static seam) decodes all six event types into ChatMessage.Kind/fields (superChat / superSticker / newSponsor / membershipGifting / giftMembershipReceived / memberMilestoneChat + milestone months best-effort regex); optional HttpClient ctor seam endures; InjectSimulatedMessage (TASK 41) preserved |
ChatBoxRenderer.cs |
Chat overlay rasterizer (WPF FormattedText → BGRA VideoFrame), live rows + Super Chat/member styling |
ChatOverlayLayer.cs |
The chat component (Commit G): owns the message buffer, ChatBoxRenderer, fade/mock timers, per-source preview renders, live RenderFrame; raster-on-change cache pattern (OBS text-source contract) |
AlertRenderer.cs |
The alert rasterizer (TASK 43, 2026-09-24): renders one event's celebration animation into a BGRA VideoFrame — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. Still the fallback when no alert clip is playable (TASK 47) |
AlertOverlayLayer.cs |
The native alert-box component (TASK 43; video branch TASK 47): event queue (cap 10) + 33ms dispatch ticker + RenderFrame cache-first live path + UpdatePreview; Advance(double) is the deterministic test clock (tests never start the timer); ChatEventKind.None rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns IAlertClipDecoder per play — ctor seams (clipDecoderFactory, clipPathResolver, audioSink), fade in/out over FadeDurationSeconds = 0.30 (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain, and AlertTickerFrame composes the scrolling message strip |
AlertClipDecoder.cs |
Per-play alert video decoder (TASK 47): IAlertClipDecoder (Start/Stop/Dispose + FrameAvailable/AudioReady/Completed) + ffmpeg child emitting rawvideo bgra (-vf scale=W:H, read via RawVideoFrameReader) and f32le -ar 48000 -ac 2 on a carry-buffer audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake |
LayoutStore.cs |
SQLite persistence (Microsoft.Data.Sqlite) at %APPDATA%\ytLlive\ytLlive.db; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema user_version 10 (Source.ClipShape/IsMirrored via ALTER TABLE for pre-v2 DBs; v3 = singleton Webcam + per-scene WebcamSceneConfig, migrated idempotently without backfill — the stale Source.DeviceId column remains but is no longer read/written; v4 = WebcamSceneConfig.RectWidth/RectHeight, the pre-Round rect for the round-to-rect restore; v5 = Source.IsBackdrop + Source.CaptureKey, the live-capture backdrop; v6 = Scene.HasBackdrop — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = YppSnapshot history table; all later columns stay additive guarded ALTERs — TASK 51 adds WebcamSceneConfig.BackgroundEffect/BackgroundStrength; TASK 47 adds the five alert Source columns (AlertVideoAssetId/AlertVideoPath/AlertUseDefaultVideo/AlertShowTicker/AlertVideoVolume) without a version bump; MainViewModel.NormalizeBackgrounds re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned BackgroundUseDefault_*/BackgroundPath_* Settings keys purged at save). Settings key/value table holds app state: MicSourceName, ReusableStream (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), AlertDefaultVideoAssetId (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) |
VideoFrame.cs |
Normalized CPU frame seam (Width/Height/tightly-packed BGRA byte[]) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
BackgroundEffects/IPersonSegmenter.cs |
TASK 51 seam: Segment(VideoFrame) → SegmentationMask? (SegmentationMask(Width, Height, float[] Values), 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes |
BackgroundEffects/OnnxPersonSegmenter.cs |
TASK 51 default segmenter: MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) |
BackgroundEffects/WebcamEffectKey.cs |
TASK 51: (Effect, Strength) variant key, strength rounded to 0.01; None normalizes to strength 0 (the raw path) |
BackgroundEffects/BackgroundEffectProcessor.cs |
TASK 51 pure pixel stage: Apply(frame, BackgroundEffect) — None = same instance, Remove = copy with alpha = feathered mask × source alpha (IsOpaque=false), Blur = background blurred at ¼ res (3 box passes) and mask-blended, person untouched; strength 0..1; blur radius is quadratic via BlurRadius(strength) — 1→6 small px (default 0.5 = 2, was a linear 1→12 ≈ 48 full px; the "blur heavy" fix 2026-10-09); TrySegment once + Render per effect; no mask/exception → passthrough + one log |
BackgroundEffects/MaskSmoother.cs |
TASK 51 jitter fix (2026-10-09): temporally blends each raw mask with the previous smoothed one — CurrentWeight 0.75 × new + 0.25 × previous — so a per-frame segmentation verdict that wavers at an edge doesn't flutter the cut-out. Null input resets history; first frame/size change pass through; the returned mask never aliases the caller's array and the two buffers ping-pong (no per-frame LOH alloc: a 256×144 mask is 147 KB). State is per camera session, not in the segmenter (which is shared app-wide and contract-pinned to raw output). Weights from the OBS/Jitsi consensus — see ai.md "Webcam background effects" |
CameraDeviceInfo.cs |
(Id, DisplayName) for a physical capture device |
ICameraEnumerator.cs |
GetCamerasAsync() — seam so the picker/CameraManager never touch WinRT (tests inject fakes) |
ICameraFrameSource.cs |
StartAsync/StopAsync/FrameAvailable(VideoFrame) + SourceFailed(string) — seam for a running capture source (tests inject fakes) |
MediaCaptureCameraEnumerator.cs |
WinRT enumeration via DeviceInformation.FindAllAsync(DeviceClass.VideoCapture) |
MicrophoneDeviceInfo.cs |
(Id, DisplayName) for an audio capture (mic) device |
IMicrophoneEnumerator.cs |
GetMicrophonesAsync() — seam so the mic picker never touches WinRT (tests inject fakes) |
WinRtMicrophoneEnumerator.cs |
WinRT mic enumeration via DeviceInformation.FindAllAsync(DeviceClass.AudioCapture) (no NAudio needed — capture libs stay deferred to the audio pipeline milestone) |
MediaCaptureFrameSource.cs |
CPU-first MediaCapture source (MemoryPreference = Cpu, BGRA8 via CreateFrameReaderAsync). Validated, not trusted: post-init checks (VideoDeviceId match, FrameSources non-empty, VideoDeviceController.GetAvailableMediaStreamProperties ≥ 1); reader.StartAsync() status read (throws on non-Success); capture.Failed + CameraStreamStateChanged subscribed → SourceFailed event. Fallback ladder: VideoPreview → VideoRecord retry. SharingMode.Exclusive + DeviceLost not in 19041 SDK projection; CameraStreamState.Failed compared by (int)2 (projection omits member names) |
CameraManager.cs |
Webcam capture ownership: refcounted by DeviceId, one shared WriteableBitmap per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); PreviewBitmapChanged/CameraFailed events. First-frame proof (4s timeout, configurable via ctor TimeSpan?): AcquireAsync returns true only after a real frame arrives — a reader that starts but never delivers (locked, suspended, dead) fails and surfaces CameraFailed with suspect-app names (CameraConflictProbe) instead of a silent empty box. SourceFailed forwarded from the frame source as async death signal. TASK 51: the one background-effects processing point — SetRequestedEffects(device, keys) + per-variant GetLatestFrame/GetPreviewBitmap(device, key) + EffectPreviewBitmapChanged; a background worker segments once per raw frame (mask temporally smoothed per camera — MaskSmoother — see ai.md "Webcam background effects") and renders every variant (latest-frame-drop); raw path untouched |
IFullScreenDetector.cs |
Seam for the win32 full-screen detector: int? GetForegroundFullScreenMonitorIndex(), int PrimaryMonitorIndex(), IReadOnlyList<DisplayInfo> GetDisplays() |
Win32FullScreenDetector.cs |
GetForegroundWindow + DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS) + MonitorFromWindow + GetMonitorInfo; monitor covers all four edges → full-screen; own process excluded; monitor order = EnumDisplayMonitors order (static GetMonitorHandle(int) maps index→HMONITOR for the capture factory). GetDisplays() returns DisplayInfo (index/name/resolution/bounds/IsPrimary, friendly name via EnumDisplayDevices) for the in-app "Capture Display" picker; PrimaryMonitorIndex() is the auto-key fallback when no full-screen game is detected |
IScreenCaptureSource.cs |
Key + StartAsync/StopAsync/FrameAvailable(VideoFrame) — seam so ScreenCaptureManager never touches WinRT (tests inject fakes) |
Direct3D11Helper.cs |
The one shared IDirect3DDevice per process: P/Invoke d3d11.dll!D3D11CreateDevice (hardware, BGRA_SUPPORT, explicit 11.1-first feature array) → QI IDXGIDevice → the WinRT interop export CreateDirect3D11DeviceFromDXGIDevice → MarshalInterface<IDirect3DDevice>.FromAbi. CsWinRT does not project Windows.Graphics.Direct3D11.Direct3D11Helper, so this hand-rolls it. Do not revert to QI-for-IDirect3DDxgiInterfaceAccess — the device no longer exposes it on newer Windows (E_NOINTERFACE on build 26200) |
CaptureInterop.cs |
ComImport bridges the WinRT-projection gap: IGraphicsCaptureItemInterop (CreateForMonitor/CreateForWindow), IInitializeWithWindow (GraphicsCapturePicker owner window so the OS picker shows) |
ScreenCaptureFrameSource.cs |
One Direct3D11CaptureFramePool (free-threaded, 2 buffers) + session per target; frames → SoftwareBitmap.CreateCopyFromSurfaceAsync (BGRA, alpha ignored) → VideoFrame, bytes read via WindowsRuntimeMarshal.TryGetDataUnsafe (CsWinRT-safe — the IMemoryBufferByteAccess ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). CreateForMonitor/CreateForWindow/CreateForPicker |
ScreenCaptureManager.cs |
Screen-capture ownership mirroring CameraManager: refcounted by target key, one shared WriteableBitmap, dispatcher-coalesced latest-frame copies; PreviewBitmapChanged/CaptureFailed events; ReleaseAllAsync used on re-designation |
ScreenCaptureSourceFactory.cs |
Resolve(key) parses monitor:<n> / window:<hwnd> / picker:<name> into a source; PickAsync() shows the OS GraphicsCapturePicker and returns the picker: key (transient — a reload falls back to auto-detection) |
WebView2Manager.cs |
Web-source ownership (TASK 17, composition-capture since 2026-09-14): per registered Source { Type: WebSource } it owns a CoreWebView2CompositionController + its visual tree (RootVisualTarget child under a 1920×1080 root ContainerVisual on one Windows.UI.Composition.Compositor created on the UI thread via the CoreMessaging CreateDispatcherQueueController P/Invoke), a wrapped WebCaptureFrameSource, and the shared crop-sized WriteableBitmap preview (dispatcher-coalesced copy at Render priority, PreviewBitmapChanged raised only on recreation). Nav + TransparentBackgroundScript injection unchanged. Public ctor (Dispatcher); internal seam ctor (Dispatcher, Func<string, IScreenCaptureSource>?) for hermetic tests (no WinRT). Caller: MainViewModel.InitWebView2() (now invoked from MainWindow_Loaded — the parent HWND must exist). See ../ai.md "Slice 14" |
WebCaptureFrameSource.cs |
The IScreenCaptureSource for a web session: owns the GraphicsCaptureItem (CreateFromVisual) + a free-threaded Direct3D11CaptureFramePool (2 buffers) + session; readback via SoftwareBitmap.CreateCopyFromSurfaceAsync (BGRA, BitmapAlphaMode.Straight — the compositor blends straight alpha); per-frame FindContentBounds alpha-bbox (internal static, unit-tested) stamps VideoFrame.CropBounds; ring up to 2 fresh frames + Epoch reuse; IsOpaque=false. Mirror of ScreenCaptureFrameSource |
Compositor/SceneCompositor.cs |
The output compositor (TASK 4 ship step 1): renders a scene into the encoder's master VideoFrame (tightly-packed BGRA8), mirroring the XAML preview minus editing chrome — backdrop → background → elements (UniformToFill cover-crop, round clip, mirror, opacity, border) → branding flash → social bar (optional socialBarFrame + socialBarTop in master space, blitted last so the bar sits above the flash; the old BlitFlash generalized to BlitOverlay with source offsets). TASK 47: Render + CompositeLayers take a trailing tickerFrame blitted at (0,0) after the bar — the alert ticker is a message-scrolling dynamic overlay, never baked. Pure and WPF-free: frames injected via a Func<SceneElement, VideoFrame?> resolver (webcam → DeviceId, image → AssetId, backdrop → CaptureKey); output sized by CompositorOptions (16:9 = full master 1:1; vertical 9:16 = 607×1080 crop → 1080×1920 bilinear). Preview stays XAML (editing view); this is the output view — see ai.md "Scene compositor". 2026-10-16 (measurement only): Render/CompositeLayers/BakeStaticBase take a trailing optional CompositeTimings? — every stage and every element timed (#<i> <name>, nested raster) and reported by the pump's parts line; zero cost for callers that pass nothing |
Compositor/CompositeTimings.cs |
Per-element composite timing (2026-10-16, measurement only): the collector the pump owns for a stats window — Add(stage, ticks) accumulates, Describe(renders, minMs = 0.1) prints the averaged breakdown slowest-first. Stage names: backdrop/base/flash/social/ticker/zoom/scale/overlay, bake backdrop/bake overlays/bake #i name, and one entry per element #<z-index> <name> with the nested raster sub-stage (paste-cache rasterize miss) — the line that names WHICH of the six elements eats the render budget. See ai.md "Per-element composite timing + scene dump" |
Compositor/CompositorOptions.cs |
The active tier's output rect (source space over the 1920×1080 master, integer-aligned — MainViewModel.OutputRectX can be 656.5) + target W×H |
Compositor/StretchMath.cs |
Pure pixel math: the WPF UniformToFill cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
Compositor/StaticPixelCache.cs |
Asset bytes → cached BGRA8 VideoFrame, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's BitmapImage) |
Encoder/IFfmpegLocator.cs |
TASK 4 ship step 2 seam: resolves an absolute path to ffmpeg.exe (PATH first → cached %APPDATA%\ytLlive\tools\ffmpeg.exe → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
SocialValidator.cs |
ISocialValidator seam + HttpSocialValidator default: validates a social handle/URL by constructing the canonical profile URL and issuing an HTTP GET — the "act of validation" before adding to the bar. 200/redirect = exists; 404/connection failure = rejected. Fediverse @user@domain additionally does a best-effort nodeinfo lookup (/.well-known/nodeinfo → software.name) so the entry can show the instance's real logo; nodeinfo failure still validates (generic fediverse glyph). If the identity domain's nodeinfo is blocked (SSO) but the bare root 302s to the real instance (YunoHost default-app subdomains), the lookup follows the redirect and asks that host. Since the bar bug-fix branch the resolution probes well-known subdomains (mastodon., social., ... order in FediverseSubdomainCandidates) when both the identity domain and its redirect come up empty, bounded by a ~15s linked-CTS budget; ResolveFediverseSoftwareAsync(domain, ct) on the seam powers the load-time heal. Constructor takes optional HttpClient + Action<string> log for tests |
Compositor/SocialBarRenderer.cs |
Social bar output strip (bar bug-fix branch): rasterizes the global social bar into a transparent straight-alpha BGRA8 VideoFrame strip (1920 wide, 40px content + 24px glow pad) via RenderTargetBitmap — one Path (logo) + one TextBlock (handle) per entry, white on transparent with the preview's green #2ecc71 glow baked in (Pbgra32 → straight-alpha unpremultiply). UI thread only; the frame is immutable afterwards so the FramePump reads it from its own thread |
Compositor/AlertTickerRenderer.cs |
Alert message ticker strip (TASK 47): marquee overlay rendered per call by pure byte-math onto a 1920×48 transparent BGRA frame — the text pill ("Author — Kind · amount") is rasterized once per text on the UI thread (cached, unpremultiplied straight alpha) and scrolled at 140px/s with a repeat-gap wrap, so the pump can call Render(elapsed, text) from its own thread without locking. Dynamic overlay — never baked (unlike the social bar) |
Encoder/EncoderOptions.cs |
One go-live's encoder config: full RtmpUrl (ingest + stream key), W×H/FPS/bitrate from the quality tier, VideoEncoder (null = probe), audio sample rate/channels, GopSize = FPS×4 (the ≤4s keyframe bound) |
Encoder/IFfmpegEncoder.cs |
TASK 4 ship step 3 seam: start/feed/stop the live encoder; HealthUpdated (parsed StreamHealth), ProcessFailed on unexpected non-zero exit. Constructor-injected locator + process factory (tests fake both) |
Encoder/FfmpegEncoder.cs |
The default encoder: spawn ffmpeg.exe (probe -encoders first → hardware NVENC/QSV/AMF, OpenH264 fallback — never libx264), feed raw BGRA frames into stdin, parse -stats lines into StreamHealth, graceful stop via stdin EOF + 10s kill watchdog. Constructed by MainViewModel since ship step 5 (new FfmpegEncoder(new FfmpegLocator())), driven by the FramePump |
Encoder/IEncoderProcess.cs |
Seam around the spawned subprocess (probe + encoder): redirected stdin/stdout/stderr + exit control — the encoder never touches Process directly |
Encoder/FfmpegEncoderProcess.cs |
The real process wrapper (Process with all three std streams redirected) |
Encoder/FfmpegArgs.cs |
Pure FFmpeg command-line builder: rawvideo pipe:0 input, anullsrc silent audio (WASAPI replaces it), H.264+AAC, closed GOP (-g fps×4 -keyint_min -sc_threshold 0 -bf 0, yuv420p), FLV → RTMP |
Encoder/FfmpegProgressParser.cs |
Pure parser for frame=/fps=/size=/time=/bitrate= stats lines → FfmpegProgress |
Encoder/FfmpegEncoderPicker.cs |
Pure H.264 encoder picker from -encoders output: NVENC → QSV → AMF → OpenH264; never returns libx264 (GPL) |
Encoder/FramePump.cs |
TASK 4 ship step 5: the live frame producer — while live it snapshots the active scene each tick, resolves every element to its latest frame (Func<SceneElement, VideoFrame?> resolver), composites it into the tier's output frame, and paces frames into the encoder at the tier's FPS. All collaborators constructor-injected seams; free of WPF and the capture managers. StartAsync never throws (failures log + Failed); no RTMP URL = encoder skipped; StopAsync stops the encoder before awaiting the loop (backpressure deadlock); ProcessFailed self-stops. Since the bar bug-fix branch it takes an optional socialBar: Func<(VideoFrame? Frame, SocialBarPosition Position)>? seam — a pre-rendered bar strip composited last (above the flash) at the top edge or SourceRectHeight − bar height. TASK 47: adds an alertTicker: Func<VideoFrame?>? ctor seam (parallel to socialBar), read per tick and threaded through RenderScene/RenderFull + mixed into BuildFullRenderSignature so the ticker scroll changes the cache key. See ai.md "Live frame pipeline". 2026-10-16: owns a CompositeTimings collector, passes it through RenderScene/RenderFull/GetBakedBase/CompositeLayers, logs FramePump parts (N renders/5s): … (per-stage breakdown, slowest first) beside the stats line each 5s window, and dumps the scene shape — FramePump scene dump: header + one line per element (z-index, name, kind, type, visibility, rect, opacity, clip) — at pump start and on every scene change |
Encoder/FfmpegLocator.cs |
The default locator: PATH probe wins, then the cache, then pull+extract (ffmpeg.exe + the libav*.dll family via a staging dir, failures via AppLog). Pinned to BtbN autobuild-2026-08-09-13-03 lgpl-shared (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see ai.md → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Wired into the encoder since ship step 5 |
INotificationService.cs |
Toast seam (TASK 24): Show(title, message, AppNotificationSeverity) + AppNotificationSeverity { Info, Success, Warning, Error } + Info/Success/Warning/Error extension methods — non-blocking user-facing messages; VMs/services raise through this so failures surface without MessageBox blocking the UI thread |
NotificationService.cs |
The default seam impl (Notification.Wpf 11.0.0, MIT): wraps a library NotificationManager; routes to the in-window NotificationArea named ToastArea (MainWindow bottom-right above the footer); lifetimes via the pure BuildRequest mapping + Cards tint table — Info/Success ~4s, Warning ~8s auto-dismiss, Error sticky (NeverExpires() = TimeSpan.MaxValue, never null); dark card tints matching the health-banner language; all Show calls marshal to the captured Dispatcher. See ../ai.md "Toast notifications" |
Audio/IAudioSource.cs |
TASK 4 ship step 4 seam: live capture source — Start/Stop/Started/SampleReady(AudioSample)/Failed(Exception), IDisposable. Capture now runs for the app's lifetime (started at startup) so the footer meters preview live. The app consumes this seam; tests inject hermetic fakes |
Audio/AudioSample.cs |
One captured chunk: interleaved PCM float (-1..1) + sample rate + channels |
Audio/WasapiLoopbackAudioSource.cs |
Desktop/game capture: NAudio WasapiLoopbackCapture on the default render device — automatic at unity; the game bar's meter consumes it |
Audio/WasapiMicAudioSource.cs |
Mic capture: NAudio WasapiCapture with the device resolved by FriendlyName matching MicSourceName (DisplayName only), default capture endpoint fallback; name provider Func<string?> re-read at each Start so a device picked mid-session takes effect immediately (the mixer restarts the mic on pick) |
Audio/AudioMixer.cs |
Owns both sources; capture starts once at startup (MainViewModel.StartMicCaptureAsync) and stops on Shutdown — NOT go-live (preview monitoring). Mic samples → AudioLevelMeter → MicLevelChanged; loopback samples → the game bar's meter via LoopbackLevelChanged. Surfaces mic connection state: MicConnected/MicFailed (drives the status dot) + RestartMic() (device swap mid-session, keeps loopback). Failures log via AppLog; mic failure zeroes the meter, loopback failure doesn't kill the mic. Note: the meter Push is unconditional (the ?. on the event would otherwise skip the argument when nothing is subscribed). TASK 47 — alert audio: EnqueueAlertAudio writes a dedicated 8s stereo-48k ring (AlertBufferSeconds), drained in FillAndMix pre-limiter and added at unity (never ducked); StartLive clears the ring |
Audio/AudioLevelMeter.cs |
Pure smoothed RMS level (0..1): Push(AudioSample) + Reset — the unit-tested math behind AudioLevel and GameAudioLevel. ToDisplay(float) maps the raw linear RMS onto the meter's display scale (−60..0 dBFS spread across 0..1 — honest since 2026-10-09: the old +10 dB display boost made a −30 dBFS voice look dialed in; leveled speech (−20 dBFS) now sits at 2/3 of the bar) — real speech/game RMS (~0.01..0.1) would otherwise leave a flat scale looking dead; ≤0.001 linear reads as zero (never idles on background noise) |
Audio/WaveToFloat.cs |
Pure WASAPI buffer → float conversion: IEEE float 32-bit direct, PCM 16-bit normalized, WaveFormatExtensible IEEE-float subformat GUID, trailing partial samples ignored |
Audio/MasterLimiter.cs |
Master output limiter: a −1 dBFS ceiling (Ceiling = 0.891) applied after the live mix so the honest gains can never sum past 0 dBFS and clip the AAC encode. Instant attack per frame (a hot frame is scaled exactly to the ceiling — no overshoot), smoothed release toward unity so loud passages don't pump. Pure — unit-tested |
Related: constructed in ViewModels/MainViewModel.cs
(no DI container yet). Models in Models/index.md.
OAuth credentials live in Helpers/OAuthCredentials.cs.