ai.md 1,308 → 228 lines. No prose rewritten: every block was moved as-is so nothing could be lost in translation, then replaced by a pointer. New files: - docs/ai/environment.md — LAN/SSH + Gitea push recipe, Windows dotnet build/run, test-suite map, hermetic real-MainWindow test rule - docs/ai/product.md — brand/naming, AI transparency, GPL licensing prohibitions, free-forever monetization, auth/account assumptions, packaging & distribution - docs/ai/youtube-api.md — go-live ordering, the broadcast-binding fix, the private-only lock, health parsing, end-stream pre-flight, Test mode, chat insert shape - docs/ai/overlays.md — social bar, native alert box (animations, clip, ticker), chat box conventions - docs/ai/ui-surface.md — control surface, scene strip, preview rail, hotkeys, broadcast drawer, toasts, transitions, brand logo, launch geometry, theme/properties conventions ai.md keeps the response/derivation rules, the architecture table, the four existing invariant stubs, and one pointer per moved subsystem. docs/ai/index.md routes every file with a "read when" column. References falsified by the move were fixed in the same commit: AGENTS.md build note → environment.md → Run, and the "Related" footers in frame-pipeline.md / output.md / capture.md now point at the owning files. Docs-only unit: scope-check run, no build (no source touched).
41 KiB
Capture — backdrop, webcam, background effects, and the vertical tier
Split out of
ai.md(2026-10-26) so a session reads only what its task touches;ai.mdkeeps a short invariant stub for this file. Directory map:index.md.
Every frame source in the app lands here: the locked Background layer (screen/game/window capture), the webcam seam and its sessions, the MediaPipe background effects, and the per-scene camera placement the vertical 9:16 tier uses. Content is the architecture plus the measured history that produced it; superseded designs are marked inline.
The locked Background layer — screen, game, window capture
Screen backdrop capture (TASK 3 ship task #1) — model reworked by TASK 25
The Background is a locked per-screen layer (TASK 25): exactly one per canonical
scene, always named "Background", pinned at index 0, never addable/removable/reorderable,
absent from the (+) menu. Flavor is decided by scene: Live = DisplayCapture (the real-time
desktop/game surface with Show Desktop / monitor switch / fullscreen-game detect, falling back
to live-background.jpg); Starting/BRB/Chat/Ending = static Background art layers.
MainViewModel.NormalizeBackgrounds(scenes) (internal static) heals any loaded layout to this
model on every load: keeps the correctly-flavored row (or converts a survivor in place — note
Source.Type's setter derives IsBackground, so conversions must re-assert the flag), drops
duplicates/stale rows, seeds missing ones via CreateBackground(name), renames, moves to index 0;
non-canonical scenes get HasBackground=false and lose any backgrounds. HealBackgrounds()
(instance) runs Normalize + stamps default art (StampDefaultBackgroundAsset reads
Assets/{scene}-background.jpg pack resources through AddAsset; custom Browse art wins).
This replaces the old five-seeder cluster (Seed{Starting,Brb,Ending,Chat}Background,
SeedLiveBackgroundAsset, EnsureDefaultBackground) and EnforceBackgroundPolicy.
- Model (schema v6):
Source.IsBackground(persisted) marks the one Background per scene;Source.CaptureKey(persisted) names the target —monitor:<n>,window:<hwnd>, orpicker:<displayname>. The Live row is a realSourceofType DisplayCapture, inserted first (MainViewModel.EnsureBackground(scene), internal static — also used by theStagedScenesetter so a scene switch can never expose a missing Background), fixed at X=0/Y=0/1920×1080, and excluded from drag/hit-test/remove/reorder (remove is guarded inRemoveElement; the element template setsIsHitTestVisible=falsefor backgrounds; the row's remove button and "Remove Source" menu item are hidden).Scene.HasBackground(persisted) is true for all five canonical scenes (seeSceneCatalog.HasBackground). Capture controls — "Change Capture…"/"Refresh Capture"/"Capture Display"/"Show Desktop" — exist only while the Live scene is staged (CanChangeBackground= staged scene is Live; the preview CanvasGrid menu binds it directly, the layer-row menu MultiBindings it with the row's ownIsBackgroundviaHelpers/AllTrueToVisibilityConverter). Non-Live screens keep the properties panel pill ("Use default background") + Browse for custom art. - Settings hygiene:
LayoutStore.Savepurges orphanedBackgroundUseDefault_{id}/BackgroundPath_{id}Settings keys whose id is no longer a live Source row (TASK 25 heal drops duplicates; their keys must not accumulate). - Layering invariant (regression lesson, 2026-08-23):
ActiveBackgroundImagerenders aboveBackgroundImage, so it is fed by static-art rows only (Type == Background). The capture-flavored Live row must never populate it or it permanently covers the desktop/game capture; Live's canvas showsDisplaySource(capture, falling back to its AssetId art). Flavor-blindIsBackgroundlookups are for persistence/heal/minis — not this overlay. - Detection (event-driven):
Win32FullScreenDetectorhooksSetWinEventHook(EVENT_SYSTEM_FOREGROUND)— fires on the UI thread whenever the foreground window changes. The callback runs the sameGetForegroundWindow+DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)+MonitorFromWindow+GetMonitorInfocheck; a window is full-screen when its frame covers all four monitor edges; own process is excluded; monitor index =EnumDisplayMonitorsenumeration order (the same orderScreenCaptureSourceFactoryuses to map index→HMONITOR viaWin32FullScreenDetector.GetMonitorHandle).IFullScreenDetectoralso exposesGetDisplays()(DisplayInfo: index/name/resolution/bounds/IsPrimary, friendly name viaEnumDisplayDevices) +PrimaryMonitorIndex()for the in-app "Capture Display" submenu +FullscreenMonitorChangedevent +StartWatching()/StopWatching(). At launchReacquireScreenCapturesheals backgrounds (HealBackgrounds()), then keys the Live background to the full-screen game's monitor; when no game is detected,ResolveAutoCaptureKey()returnsnull— staleCaptureKeyvalues are cleared, no capture session is started, andSource.DisplaySourcefalls back to_imageSource(the static asset on the backdrop element).StartWatching()is called at the end ofReacquireScreenCaptures.OnFullscreenMonitorChangedauto-redesignates when a fullscreen game appears on a different monitor; null (no fullscreen / own window) is ignored — the existing capture keeps running so loading screens / transitions don't break the stream.RefreshBackdropAutoCapture(bound to "Refresh Capture" context menu +Activatedhandler) reads the current foreground directly as a manual fallback. Deduplication:_lastReportedMonitorprevents redundant redesignates from rapid foreground changes. - Capture (WinRT GraphicsCapture):
ScreenCaptureFrameSourcecreates a free-threadedDirect3D11CaptureFramePool(2 buffers,B8G8R8A8UIntNormalized) +GraphicsCaptureSession; frames →SoftwareBitmap.CreateCopyFromSurfaceAsync(alpha ignored) →VideoFrame(BGRA8), bytes read viaWindowsRuntimeMarshal.TryGetDataUnsafe(the same CsWinRT-safe read the webcam path uses) — theIMemoryBufferByteAccessComImport cast threwInvalid caston every frame under CsWinRT, which floodedstartup.log(~5 MB in a session) and burned CPU, so it is gone. Surfaces larger than the 1920×1080 master are downscaled to the master (DownscaleBgra, integer 8.8 fixed-point bilinear — slice 16: the same two-stage math asSceneCompositor.Bilinear; the previous double-per-pixel version was ~30-45ms quiet / ~150ms under 240Hz-HDR load and froze the desktop layer ~90% of a take). Conversions run as up to MaxConcurrentConversions (3) overlapping readbacks (slice 17: the OS readback, not the downscale, is the ~47ms wall — see Slice 17) with a monotonic LatestFrame publish gate (MonotonicGate: a slow OLDER completion can never overwrite a newer frame), and are spaced by a 10ms floor (MinConvertInterval): the monitor delivers at the 240Hz DWM cadence (~4.2ms), far too fast for the ~60/s the pump can use, so the extra arrivals are dropped (skip busy/skip cadencetelemetry). Hand-out buffers come from a reuse-distance ring (FrameRingBuffer, depth 8, redLine 4): a buffer is only rewritten ≥4 rents after its last hand-out, else a fresh one is allocated — a frame a consumer still holds (session.LatestFramesurvives conversions; the dispatcher preview copy lags) can never be read-while-overwritten (the 1742 tear). Rolling telemetry (frames/s, conv avg/max ms, drops, ring allocs) is logged tostartup.logevery 2s while converting. Per-frame conversion failures are logged at most once per 5 s (ErrorLogThrottle). DRM-protected content delivers black frames (OS limitation, documented). Frame pool pauses while the app is minimized — capture keeps running, the pool just stops delivering. - Ownership:
ScreenCaptureManagermirrorsCameraManager— refcounted by target key, one sharedWriteableBitmapper key, dispatcher-coalesced latest-frame copies,PreviewBitmapChanged/CaptureFailedevents,ReleaseAllAsyncon re-designation.ScreenCaptureSourceFactory.Resolve(key)parses the key into a source;PickAsync()shows the OSGraphicsCapturePicker("Change Capture…", owner window set via theIInitializeWithWindowComImport) and returns a transientpicker:key — a reload falls back to auto-detection. - "Capture Window…" — in-app window pin (TASK 38, 2026-09-21): the Live backdrop context menus
(PreviewPane canvas + layer-row) offer a submenu listing visible top-level windows of other processes
(
Win32WindowEnumerator, anEnumWindowspass filtered likeWin32FullScreenDetector: visible, non-cloaked viaDWMWA_CLOAKED, non-empty rect, other-process, titled). Picking one runsRedesignateBackgroundAsync("window:0x{hwnd:X}")— the same full-bleed layer-0 capture path as the game/desktop, keyed straight intoScreenCaptureSourceFactory(window:already resolved there). Session-scoped, never persisted:window:(andpicker:) keys areIsTransientCaptureKey—ReacquireScreenCapturesstrips them before auto-detection on every load, because an HWND can be recycled to an unrelated window after restart (resurrecting a dead pin could capture the wrong window). Pin wins while alive:OnFullscreenMonitorChangedskips re-designation whileIsAliveWindowPin(current.CaptureKey)is true — the auto game/desktop detector never steals an explicit pin. No dead ends: if the pinned window's capture session dies mid-session,OnScreenCaptureFailed(transient key only) re-runsReacquireScreenCaptures→ auto-fallback (fullscreen game → desktop → static art). Seam:IWindowEnumerator+WindowInfo(Label = "Title — Process"),WindowEnumeratorOverridestatic test seam mirrorsCameraEnumeratorOverride. - CsWinRT projection gaps hand-rolled:
Windows.Graphics.Direct3D11.Direct3D11Helperis not projected, soDirect3D11HelperP/Invokesd3d11.dll!D3D11CreateDevice(hardware, BGRA_SUPPORT, explicit 11.1-first feature array) → QIIDXGIDevice→ the WinRT interop exportCreateDirect3D11DeviceFromDXGIDevice→MarshalInterface<IDirect3DDevice>.FromAbi(one shared device per process). Do not switch back to the QI-for-IDirect3DDxgiInterfaceAccesstrick: the raw D3D11 device no longer exposes that interface on newer Windows (verified E_NOINTERFACE on build 26200, hardware and WARP alike) whileCreateDirect3D11DeviceFromDXGIDevicekeeps working.IInitializeWithWindoware ComImports inCaptureInterop.cs. All WinRT projections were verified by reflection against the builtMicrosoft.Windows.SDK.NET.dllbefore writing the interop. - Known v1 limits: full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder task); multi-monitor live re-targeting beyond the auto-detected game is behind the picker; picker- and window-pin captures don't survive reload (session-scoped by design — see the "Capture Window…" bullet).
- Focus-loss capture lag (known OS limit, NOT an in-app throttle — deferred): when the app window loses
focus the preview visibly slows (mouse-movement lag). Nothing in the repo checks focus to slow capture —
the only focus hooks (
FullscreenMonitorChangedevent +RefreshBackdropAutoCapture) re-target the backdrop, never pace it. The lag is inherited:Windows.Graphics.Captureis content-driven and DWM/OS-throttled while the app is in the background (worse under a full-screen game on 24H2/26100), GPU readback (CreateCopyFromSurfaceAsync) contends with the foreground game, the source's one-in-flight_framePendinggate drops frames during stalls, and the manager'sDispatcherPriority.Rendercopies only run as fast as WPF presents the window. Slice 16 shrank the per-conversion stall (fast downscale + 10ms cadence floor) but the OS-level delivery throttle under focus loss remains. Recorded 2026-08-13; no mitigation attempted yet (deferred by user decision). - GPU posture: same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred to the encoder task.
- Preview watermark: the "Preview" placeholder hides while a background capture renders —
ShowPreviewPlaceholderalso checksBackgroundImage(raised on change), so a scene with live capture shows the feed instead of the "nothing here" label.
Webcam — capture seam, sessions, and background effects
Webcam capture (TASK 3 milestone 1)
- Seam-first: everything above the WinRT layer speaks only
VideoFrame(normalized tightly-packed BGRA8) +CameraDeviceInfo/ICameraEnumerator/ICameraFrameSourceinterfaces. Tests inject fakes; screen capture and background removal later feed the same seam. - CPU-first:
MediaCaptureInitializationSettings { MemoryPreference = Cpu, StreamingCaptureMode = Video, SharingMode = SharedReadOnly }, frames pulled viaCreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8)— the pipeline does any format conversion, so everyFrameArrivedyields a ready BGRA8SoftwareBitmap(bytes read viaWindowsRuntimeMarshal.TryGetDataUnsafe, not marshalled copies). - Reader-output subtype ladder (2026-09-15 webcam-take fix): the reader is created per-candidate
by
ReaderSubtypeCandidates(activeSubtype)— Bgra8 for uncompressed cameras (fast path), NV12 then source-default for cameras sitting in MJPG. A camera another app left in MJPG mode cannot be read as Bgra8 by aMediaFrameReader(StartAsync→OutputFormatNotSupported; the MJPG pipeline exposes NV12, not BGRA) — exactly what a browser grabbing the webcam produces. When the app can't re-negotiate (ourSetMediaStreamPropertiesAsyncrefuses with "file in use / CaptureMode is SharedReadOnly" under msedge + NVIDIA Broadcast contention), an MJPG-active reader otherwise aborts the whole acquisition. Each candidate is allocated AND started under its OWN catch — WinRT answers an unsupported subtype with a THROW (E_INVALIDARG), not a status; one rejected format must never abort the ladder. Failures degrade to the next candidate; rejections are logged + collected into the final error; only the outer catch nets device-level errors (seeMyMistakes.md→ WINRT RESOURCE-ALLOCATION RECIPE). Anything not Bgra8 is converted inOnFrameArrived, which already handled non-BGRA software bitmaps. - Source pick, not first hit: the frame reader is bound to the first source that is
VideoPreview(preferred) orVideoRecord, not blindly the first preview source. If a camera exposes neither, the failure names the device and the stream types it does expose.SharedReadOnlylets the capture coexist with other apps that share the camera. - Known failure: NVIDIA Broadcast — it opens the physical webcam exclusively, so
InitializeAsyncfails with "camera in use" (or, if init slips through, the device exposes no preview source). Fix: quit Broadcast while streaming, or pick its virtual "NVIDIA Broadcast" device from the picker and the app captures the processed feed. This is a real-device finding (LogitechVID_046D&PID_082D). Correction (2026-10-05): Windows Studio Effects is NOT a second option of this kind — it runs inside the physical camera's own driver, so it never appears as a selectable device. Only tools that install a virtual camera (NVIDIA Broadcast, XSplit VCam) do. "Pick the vcam" is a user action in the camera picker, never an automatic substitution: creator ruling 2026-10-05 — the app must not bypass the user's explicit camera choice, so there is deliberately no fallback-camera setting. - TFM:
net8.0-windows10.0.19041.0(app + tests) pulls the WinRT projection from the SDK reference packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy camera toggle still applies).EnableWindowsTargetingkeeps WSL builds working. - One webcam, many scenes (schema v3) — app-level default (2026-09-17): a singleton
Webcamrow holds the identity (Id/DeviceId/Name); each scene gets its ownWebcamSceneConfig(position/size/clip/mirror/border/IsVisible).Scene.Elementsholds images (Source) and, at most once, the webcam (WebcamSceneConfig);Scene.WebcamConfigis the accessor.CameraManagerrefcounts capture sessions byDeviceId(a session starts atRefCount = 1; repeat acquire bumps it; the last release stops + disposes). The webcam is an app-level resource — ONE selection (the app default), usable in every scene that can host it. "Add Webcam" places the existing default DIRECTLY (no picker); the Windows camera picker runs only for the initial selection (_webcam == null). Choosing a different camera in the picker swaps it app-wide viaSwapWebcamIdentityAsync(same path "Change Webcam…" uses). Per-scene gate, not app-wide (TASK 26 superseded by creator directive 2026-09-17):CanAddWebcam=StagedScene is { WebcamConfig: null } && IsWebcamAttainable— a scene holding its own placement greys the row (one webcam per stream), but ANOTHER scene holding one does not, so Chat's camera never greys Live. Attainable means a live lock, not just a saved identity:IsWebcamAttainable=_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)— an identity whose device was unplugged, or whose session can't start, leaves the row greyed withWebcamAddToolTip= "No webcam is currently available…" (identity alone no longer suffices). The max-1 reason gets the gentle "a second camera means you've graduated to OBS" line. Removing a scene's placement keeps the identity (it's the app default;_webcamis never nulled by removal) — Add stays offered for the same camera, because the app holds its own base lock (below). - Startup resource lifecycle (2026-09-17): the webcam is a resource the app allocates and
locks, validated at startup — this answers "why is the WebCam row greyed" from inside the app
instead of a DB spelunk.
MainViewModel.ValidateWebcamResourceStartupAsync(fired fire-and-forget right afterLoadLayout, stored asWebcamStartupValidationTaskso tests await it) enumerates the OS once and tri-states on the count: 0 → app runs on, layer inactive, no alarm; 1 → attemptCameraManager.AcquireAsync(device)as an app-wide lock — on failure a persistent red alert (WebcamLockAlert, bottom of the Layers panel with a Retry button) re-polls every 5 s (_webcamLockPollTimer) until a lock succeeds, and any first real frame also clears it; ≥2 → deliberately no auto-lock — selection belongs to the App Settings dialog (gear) queued — TASK 40 Unit A (App Settings round, plan saved 2026-09-22). The single-camera branch always acquires (it no longer skips when configs already hold the session): that acquire is the app's own BASE ref, so removing every scene's placement leavesRefCount = 1and the session alive — the gate stays satisfied and the Web Cam row stays offered, i.e. the app default outlives the scenes that render it. Acquire on a running session just bumps the refcount.CameraManager.IsRunning(deviceId)= a session exists (started OR still starting — a mid-start session is not "locked yet", it's in flight), and a rolled-back (failed) session leaves the dictionary so the pass can retry. After a successful single-cam lock, the pass adopts the camera as the app default if no identity exists yet (fresh layout) — the Web Cam layer is then offered in Live/Chat immediately (CanAddWebcamtrue); an already-loaded identity is never overwritten. Test seams mirrorLayoutPathOverride:CameraEnumeratorOverride/CameraFrameSourceFactoryOverrideletWebcamStartupResourceTestsdrive the probe without real hardware (0-cam no-alarm, 1-cam locked → identity adopted, lock-fail → alert → clears on retry). Attribution correction: the "NVIDIA Broadcast opens the webcam exclusively" failure below is a suspect-list claim —CameraConflictProbereads only running process names, no device handles; contention symptoms fit shared-mode/bandwidth just as well (seeMyMistakes.md). The YouTube Chat layer follows the same one-per-layout rule (TASK 27):CanAddYouTubeChatgreys out the (+) item while any scene carries a ChatBox source (raised on staging + elements change;AddSourcerefuses a second as defense in depth). Chat layers created before the "YouTube Chat" default still carried the legacy name "Chat" (commit65641d8era) — LoadLayout heals exactly that un-renamed default to "YouTube Chat"; creator renames are untouched. - Round→rect restores the aspect (persisted, schema v4):
SceneElement.ToggleClipShape()snapshots the rectangular Width/Height into publicRectWidth/RectHeightbefore going Round and restores them when switching back — otherwise the Round resize lock (square) would leave a square behind. The rect dims are persisted (WebcamSceneConfig.RectWidth/RectHeight, nullable), so a reloaded Round webcam still restores its pre-Round aspect instead of staying square. A one-timeHealLegacySquareRect(load only) widens a pre-v4Traditionalconfig that ended up square to 16:9 (keeps height; Round and explicit rect dims are untouched). - Device swap / layout reload:
ChangeWebcamAsync(picker) andReacquireWebcam(after load) release the old device withReleaseAllAsync— a forced full drop that zeroes the refcount and stops the source regardless of how many scenes held it (the per-config count isn't known once the scenes are replaced) — thenAcquireAsyncthe new device once per config. - Shared bitmap, coalesced updates: one
WriteableBitmapper active camera, created on the UI thread at the device's frame size (first frame), forwarded to everyWebcamSceneConfig.VideoImageSourceviaPreviewBitmapChanged. Frames arrive on a worker thread;CameraManagercoalesces onto the dispatcher (at most one pending copy per session, atRenderpriority, always copying the latest frame) so a 60fps device never drowns the render thread. - Webcam added mid-session must get the live frames:
PreviewBitmapChangedfires once (the first frame creates the shared bitmap); later frames only mutate that bitmap in place, so a config that didn't exist at first-frame time would never receive it — the empty/transparent container you'd see adding a webcam to Chat while Live already had the camera.CameraManager.GetPreviewBitmap(deviceId)exposes the current shared bitmap;AddWebcamToActiveSceneAsyncassigns it to the new config right beforeAcquireAsync, andReacquireWebcamre-propagates it to every config after a reload. - Clip/mirror/border: per-element
ClipShape(Traditional rectangle / Round ellipse) +IsMirrored(ScaleX = -1) + the OSB-standard static border (BorderColor#RRGGBBor""=none,BorderOpacity0–1,BorderWidth0–20,BorderAnimationNone|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow| Electricity|Sparkles). Rendered in the preview DataTemplate; toggled from the element's right-click context menu (webcam menu: Change Webcam…, Border Effect submenu — all 9 items enabled, values persist, rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty preview canvas has its own Show Webcam entry.- The Round webcam is an
Image Stretch="UniformToFill"with anEllipseGeometryclip (Center=0.5,0.5RadiusX/Y=0.5), inside aViewbox Stretch="Uniform"holding a1x1Grid, so it renders as a true circle (diameter = the shorter element dimension) instead of an oval stretched to the element rect — the traditionalImagekeepsUniformToFillover the full rect. The clip is geometry, not anImageBrush: a brush re-rasterizes the frequently-updatedWriteableBitmapper frame on the render thread, which is what made the live webcam crawl while round. The Round border is a centeredEllipseatWidth/Height = RoundBorderSize. - Resizing locks to a square (
_resizeAspect = 1) whileClipShape == Round. - Webcam size clamp:
ClampWebcamToBounds(config, sceneName)(internal — test seam) enforces the max per dimension at resize + load and no less than 10% of the master (192×108). The cap is picked by canonical scene name: 50% per dimension (960×540) everywhere except the Chat scene, which may reach half the screen area (~1358×764 @16:9) so the viewer sees the creator better (MaxWebcamWidthFor/MaxWebcamHeightFor; a renamed Chat loses the bigger cap).RoundBorderSizefollows the clamped height.WebcamSafeguardTestsguards the clamp. - Hit-testing: a
GridwithoutBackgroundonly hit-tests where its children draw, so clicks in the empty corners of a round clip fell through toWindow_PreviewMouseLeftButtonDownand deselected the element — making the corner handle ungrabbable. The element Grid carriesBackground="Transparent"(whole rect draggable; the empty canvas Grid uses the same trick for right-click Show Webcam) and theSelectionOverlay(dashed border + corner dot) isIsHitTestVisible="False"so it never intercepts the click. Two things make the webcam menu work: (1) theContextMenupins its ownDataContexttoPlacementTarget.DataContext— aContextMenuisn't in the visual tree, so without it the Click-handlerDataContext:patterns (and the IsChecked/slider bindings) silently fail; (2)Themes/Controls.xamlships a full darkMenuItemtemplate —PART_Popup(submenu popups), a popupItemsPresenter(the Border Opacity/Thickness sliders live in Items, so they render in a hover flyout), a✓checkmark column, and a›arrow driven byHasItems. An earlier bareBorder + Headertemplate dropped all three: submenus never opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the Click handlers were fine.
- The Round webcam is an
- GPU posture: webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred to the encoder task.
- Background removal — REVERSED, now IN for v1 (creator ruling 2026-10-08, TASK 51). The
2026-10-05 "permanently out" ruling stood on "an in-app model means a runtime, a GPU/driver matrix,
and per-scene tuning". The delivery shape changed: a ~430 KB Apache-2.0 model embedded in the exe,
run on the CPU by onnxruntime — no download, no GPU requirement, no driver matrix. NVIDIA's Video
Effects SDK stays ruled out (RTX-only + ~1 GB user-side download). See "Webcam background
effects" below and
TASKS/task-51-webcam-bg-effects.md.
Webcam background effects (TASK 51 — built 2026-10-09; jitter fix 2026-10-09)
- Model: Google's MediaPipe Selfie Segmenter, landscape (Apache-2.0, model card 2021-05-06),
official file
storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/ float16/latest/selfie_segmenter_landscape.tflite. Converted to ONNX by our owntools/selfie-seg/convert_tflite_to_onnx.py(the graph ends in MediaPipe's custom opConvolution2DTransposeBias, which tf2onnx/tflite2onnx can't convert — 11 op types, so a direct translation was smaller than any workaround). Verified against Google's own runtime:verify_against_mediapipe.pyfeeds the same frame to MediaPipe'sImageSegmenter(original .tflite) and onnxruntime (our .onnx): max |diff| 5e-5, 100% binary agreement on two images. ⛔ Do NOT swap in PINTO model-zoo #109 (it's ML Kit's model, extracted — not Apache provenance). obs-backgroundremoval is GPL-3.0 — license-compatible since LlamaCasty went GPL-3.0 (2026-10-09), but none of its code is used; if any is ever borrowed, it needs its copyright notice kept. - Contract: input
[1,144,256,3]RGB /255 NHWC → output[1,144,256,1]person probability. The whole frame is stretched (not cropped) to 256×144, so the mask maps back by normalized coordinates. - Shipping:
Assets/Models/selfie_segmenter_landscape.onnxis an EmbeddedResource (in git, inside the exe — nothing to lose next to it, nothing fetched at runtime). Runtime =Microsoft.ML.OnnxRuntime1.31.0 (MIT), CPU execution provider, 2 intra-op threads. Note the nativeonnxruntime.dllis ~17 MB (win-x64); an RID-less build copies every RID's native folder — the MSIX publish (TASK 48) must pin the RID so only win-x64 ships. - Code (
Services/BackgroundEffects/):IPersonSegmenterseam (Segment(VideoFrame) → SegmentationMask?, tests inject fakes);OnnxPersonSegmenter(lazy session from the embedded resource; never throws — a load/run failure logs once and returns null forever after);BackgroundEffectProcessor.Apply(frame, BackgroundEffect)—None= same instance (true passthrough),Remove= copy with alpha = bilinear-upscaled mask × source alpha, RGB untouched,IsOpaque = false(the compositor's straight-alpha blend shows the layers below), strength = edge feather (mask band 0.5±(0.05+0.45·s) → alpha);Blur(slice 2) = background downscaled 4× by box average, 3 separable box-blur passes and bilinear-upsampled, blended by the mask — person pixels (mask = 1) are copied untouched, opacity preserved. Blur radius is quadratic (BlurRadius(strength): 1→6 small px, default 0.5 = 2 — the "blur heavy" fix 2026-10-09; the old LINEAR 1→12 small px ≈ 48 full px was far beyond what the established tools use: StreamFX defaults its Box blur Size to 5 and its author stops downscale-based blurs around 8 px). The mask is computed once per frame (TrySegment) and rendered per effect (Render), so several variants share one inference. No mask / segmenter exception → passthrough + one log line, never a dropped layer. - Config (slice 3): per scene, on
WebcamSceneConfig—BackgroundEffect+BackgroundStrength(0..1, default 0.5), persisted as two additive guarded columns onWebcamSceneConfig(BackgroundEffect TEXT DEFAULT 'None',BackgroundStrength REAL DEFAULT 0.5, no version bump — the TASK 47 convention).RemoveBackground/BlurBackgroundare the radio-pill bools the UI binds. - Hookup (slice 4) — the ONE processing point is
CameraManager. Effects are per scene but the camera session is shared, so the manager renders variants keyed byWebcamEffectKey(effect, strength rounded to 0.01).MainViewModel.SyncWebcamEffects()declares the union of every config's key (SetRequestedEffects; unused variants are dropped so their CPU stops) and points each config'sVideoImageSourceat its variant's bitmap; it re-runs on load, add, remove, device swap, and anyBackgroundEffect/BackgroundStrengthchange (configs are watched viaPropertyChanged, which alsoScheduleSaves). Per raw frame a background worker (latest-frame-drop, one at a time) segments ONCE and renders every variant; each variant keeps its own latest frame (the resolver:GetLatestFrame(device, key)) and WriteableBitmap (EffectPreviewBitmapChangedon first creation). The raw path is untouched — a slow/failed model never stalls the plain webcam — and an effect variant shows nothing until its first processed frame (Remove never flashes the real room). The compositor needed no change: its sampler already honours source alpha. - Temporal smoothing (2026-10-09, creator check: "removal is jittery"): the segmenter classifies
every frame independently, so a verdict that wavers at an edge makes the cut-out flutter. Fix:
MaskSmootherblends each raw mask with the previous smoothed one — 0.75 × new + 0.25 × previous (CurrentWeight, internal const). State is perCameraSession(one per-camera worker), NOT in the segmenter — theOnnxPersonSegmenteris shared app-wide and its bundled-model contract test pins the raw per-frame output. A null mask (model hiccup) resets history; the first frame and any size change pass through un-blended; the returned mask never aliases the caller's array and two buffers ping-pong (no per-frame allocation — a 256×144 mask is 147 KB, past the 85 KB LOH). Weights come from the established tools, not a guess: OBS's obs-backgroundremoval smoke-teststemporal_smooth_factorat its 0.85 default, Jitsi's webcam background filter (jitsi/jitsi-meet#17080) found 0.7/0.3 transformed best — a midpoint that still reacts to real movement; 0.5/0.5 leaves a smearing edge ghost. The blur "heavy" follow-up was fixed the same day: quadratic radius, 1→6 small px (see theBlurbullet above). - Perf (synthetic 1280×720, Debug build): segment 1.6 ms, Remove 3.3 ms, Blur 17.5 ms per frame
(Release is several × faster). Pixel loops are row-parallel, capped at min(4, cores/2); the
bilinear upsampling is separable (horizontal rows precomputed per mask/small row, one vertical lerp
per pixel) — the first naive per-pixel 2-D bilinear cost 46/80 ms.
CameraManagerlogs the real average once per session after 120 processed frames (startup.log: "background effects avg …").MainViewModel.PersonSegmenterOverrideis the hermetic-test seam. - UI (slice 5, creator spec 2026-10-09): the webcam's properties in the Layers panel
(
Controls/LeftPanel.xaml) gain a Background section — twoPillToggleswitches, Remove and Blur, bound toRemoveBackground/BlurBackground(radio-exclusive: one on turns the other off, turning the active one off = None) and a Strength slider (0..1, 5% snaps, visible only while an effect is on; Blur = amount, Remove = edge softness). Dragging the slider never blanks the webcam: a new strength variant inherits the dropped same-effect variant's last frame + bitmap (CameraManager.SyncVariants), and the VM keeps the shown bitmap until the new one renders. - Tests:
BackgroundEffectEngineTests— the Remove integration test (fake segmenter), and a model contract test that runs the BUNDLED model through the C# preprocessing on a fixture and matches the MediaPipe-produced reference mask (≥99.5% agreement; fixture = MediaPipe's public test imagemozart_square.jpg, public-domain painting, inytLive.Tests/Fixtures/).CameraManagerTests— the ONE integration test for the jitter fix (aSteppingMaskSegmenterwhose two consecutive masks disagree at an edge; asserts the second frame keeps a partial alpha where the raw mask would have cut to 0: x=6 → 58 upstairs of the raw 0, person core 255, background 0).Blur_default_strength_is_gentle_and_full_strength_is_strongpins the quadratic radius mapping (BlurRadius(0.5)= 2,BlurRadius(1)= 6) and the behaviour at a step edge: default smears light ~9px into the dark half vs ~26px at full strength.
The vertical (9:16) tier — per-scene camera placement
Vertical (9:16) tier camera placement (TASK 57 — "vv" Units 1+2, built 2026-10-21)
The problem. The vertical tier (1080×1920) is a centred band crop of the SAME 1920×1080
master: OutputRect* = x 656.25, y 0, w 607.5, h 1080. A camera placed for 16:9 (the default
webcam sits at x≈1408) lands outside the cropped window entirely — in the vertical stream the
creator is simply not in the picture.
The rule (creator's locked table, 2026-10-17). Each scene keeps its own vertical placement — position, size and shape — and switching back to 16:9 restores the desktop layout exactly:
| mode | shape | size | position |
|---|---|---|---|
| 16:9 | as today | as today | as today |
| vv · Live | circle or rect (the creator's choice, per scene) | small tile | one of six spots: top/bottom × left/centre/right |
| vv · Chat | rect only (the circle option is not offered) | the small tile (normal), or enlarged to fill one 607×540 area | the same six spots; enlarged fills the top or the bottom area |
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
Design — geometry only, no render-path change. The preview and the compositor both read the
element's OWN X/Y/Width/Height + ClipShape, so the tier switch swaps the live
geometry instead of teaching the render path about tiers:
WebcamSceneConfigcarries the vertical placement (VerticalX/Y/Width/Height/VerticalClipShapeVerticalRectWidth/Height— seven additive DB columns, NULL = "this scene has no vertical placement yet") and the parked 16:9 layout (LandscapeX/…/LandscapeRectHeight, transient).
BeginVerticalLayout(...)parks the live 16:9 layout and brings the vertical one in;EndVerticalLayout()commits the vertical placement back into the stored slot and restores the parked 16:9 layout. The live geometry IS the render input → the compositor andPreviewPane.xamlare untouched.- The save keeps the X/Y/W/H/ClipShape columns as the 16:9 layout (while the vertical tier is
live, the parked values are written there and the live ones into the
Vertical*columns). SceneElement.X/Y/Width/Heightbecame virtual so the webcam can CONSTRAIN every write (drag, resize handle, properties fields) into the band — one choke point instead of three writers re-implementing the rule. Outside the band = invisible in the vertical stream.
The band and the six spots (Models/VerticalCameraLayout.cs, pure math): the band is
656.25…1263.75 × 0…1080; it splits into two 607×540 areas (top/bottom), each with three columns
(left/centre/right) → six spots. A tile snaps to the spot its centre falls in, inset 32 master
px from the band's outer edge (the app's own placement margin) and never outside the band. The
default tile is 270×152 master px (480×270 in the 1080×1920 output) in the bottom-right
spot — the corner the 16:9 default webcam uses. The Live scene gets that tile automatically the
first time the vertical tier is picked (otherwise the vertical stream has no camera at all); the
other scenes keep their 16:9 placement until the creator drags them into the band.
The Chat scene's own rules (Unit 2). In the vertical tier the Chat camera is rect only:
the circle is not offered (MainViewModel.CanToggleClipShape is false → the Shape button
disappears) and the model refuses it too — WebcamSceneConfig.ClipShape is overridden, so a
locked camera cannot become Round by ANY writer (the toggle, the button, the placement swap). The
lock is WebcamSceneConfig.VerticalShapeLocked, set by the VM per scene and cleared before the
16:9 layout comes back, so the desktop shape is restored. Its size is discrete: the small tile
(normal) or one whole 607×540 area (enlarged) — the resize handle snaps to the nearer of the two
(VerticalCameraLayout.SizeFor), and an enlarged camera fills the area its centre is in (top
or bottom). The Chat scene also gets its tile automatically the first time the vertical tier is
picked (like Live) — otherwise the vertical chat stream has no camera at all.
Manual entry (creator rule): the webcam's Layers properties gained a Geometry section (X/Y/W/H) — it edits the LIVE placement, i.e. the one the current tier shows. The webcam's geometry setters clamp it into the band, so a typed value can never push the camera out of the cropped window.
Derivative work. OBS has no built-in way to store a scene's layout per aspect ratio — the maintainers' answer is "create a new empty scene and add the scene you want to send as source" (obsproject.com/forum/threads/…161307), and Streamyard's dual-aspect guide says the same ("no built-in way to store separate layouts per aspect ratio, so you rely on manual workarounds and plugins" — streamyard.com/blog/dual-aspect-ratio-streaming-tool). Our per-scene, per-tier stored placement, resolved on the tier change, is the step past that.
Tests. VerticalWebcamLayoutTests — the ONE integration test for Unit 1: the vertical tier
is picked → the Live camera comes INTO the band as a small tile → dragged to the top-right it
lands top-right inside the band → back to 16:9 the desktop layout is restored exactly → and both
placements survive a save + reload. (The store writes the webcam rows only when the app has a
webcam identity — the config rows hang off it — so the test supplies one.)
VerticalChatCameraLayoutTests — the ONE integration test for Unit 2: the vertical tier is picked
→ the Chat camera comes into the band as a small RECT tile → the circle option is not offered and
cannot be set even on the model → dragged up and enlarged it fills the top area exactly → back
to 16:9 the desktop layout is restored exactly and the circle option is offered again.
Known trap (see MyMistakes.md): a second properties panel bound to SelectedElement.IsWebcam
broke WebcamBackgroundPillTests — that test finds the webcam's panel by binding text, so a
new panel must use a different binding (IsWebcamSelected) or the selector matches twice.
Related files
ui-surface.md— resolution tiers (OutputRect*, the tier list, the vertical tier's 607×1080 band) and key patterns (MVVM/binding conventions the capture UI follows).environment.md— Tests (hermetic real-window rules camera tests must obey) and Run (launch/take procedure, the Release-only rule for measurements).