Files
gramps 7827d239e2 docs(commercial): one source of truth for the money — prices, entitlement, channels
The commercial plan was spread over five documents written in five different weeks,
and they contradicted each other: product.md sold the Store, decisions.md called the
Store dead then re-opened, Distribution.md (736 lines) still described Polar as a
download host, MARCOM.md quoted "no subscription" next to a paid tier, and README.md
promised "free forever". An outsider could not tell what ships.

docs/ai/commercial.md is now the only place money is decided: free = whole app at a
921,600 px / 30 fps output profile covering the recording as well as the stream; Pro =
$10/mo or $400 lifetime through Polar, key bound to the buyer's YouTube channel ID via
a claim page, 2 activations, 7–14 day lease with offline grace, fail closed, never
downgrade a running stream; one portable zip + winget primary, Steam and the Store
parked; Azure Trusted Signing (~$9.99/mo), no EV; Founding 200 promo; ~$135/yr costs;
anti-piracy posture and what is permanently rejected.

Everything else now points at it instead of restating it:
- product.md — money sections replaced with pointers; packaging rewritten to
  winget-first, Store parked; signing updated to Trusted Signing.
- decisions.md — graveyard with cause of death for free-forever, the Store ordering,
  the updater, and the 2026-10-30 reversal of the quality-ceiling rejection.
- Distribution.md — rewritten to release mechanics only (artifact, GitHub Releases,
  winget manifest, signing, checklist, takedown): 736 → 120 lines.
- TASKS.md — TASK 54 rewritten, TASK 48's route settled, TASK 10 superseded, five new
  rows (entitlement seam, output-profile gate, licence UI, system messages, packaging).
- README.md — "free forever" replaced with the real split.
- capture.md — obs-backgroundremoval is GPL-3.0 and so licence-INCOMPATIBLE since the
  proprietary EULA; the old "compatible" line was falsified by that change.
- TASKS/task-10, task-48, task-51, task-54 — superseded-banner at the top of each, so
  none of them reads as current policy.
- HANDOFF.md — rewritten: plan settled, next code unit is the entitlement seam.

MARCOM.md and MONETIZATION.md (gitignored) were corrected in place: policy pointer at
the top of each, the flash removed from every live claim, the Polar fee break-even
corrected to ~$900/mo, and the projections flagged as written against a dead model.
2026-10-31 08:32:25 -07:00

484 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Capture — backdrop, webcam, background effects, and the vertical tier
> Split out of [`ai.md`](../../ai.md) (2026-10-26) so a session reads only what its task touches;
> `ai.md` keeps a short invariant stub for this file. Directory map: [`index.md`](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>`, or `picker:<displayname>`. The Live row is
a real `Source` of `Type DisplayCapture`, inserted **first** (`MainViewModel.EnsureBackground(scene)`,
internal static — also used by the `StagedScene` setter 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 in `RemoveElement`;
the element template sets `IsHitTestVisible=false` for
backgrounds; the row's remove button and "Remove Source" menu item are hidden). **`Scene.HasBackground`
(persisted) is true for all five canonical scenes** (see `SceneCatalog.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 own `IsBackground` via `Helpers/AllTrueToVisibilityConverter`). Non-Live
screens keep the properties panel pill ("Use default background") + Browse for custom art.
- **Settings hygiene:** `LayoutStore.Save` purges orphaned `BackgroundUseDefault_{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):** `ActiveBackgroundImage` renders *above*
`BackgroundImage`, 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 shows
`DisplaySource` (capture, falling back to its AssetId art). Flavor-blind `IsBackground` lookups are for
persistence/heal/minis — not this overlay.
- **Detection (event-driven):** `Win32FullScreenDetector` hooks `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)`
— fires on the UI thread whenever the foreground window changes. The callback runs the same
`GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` +
`GetMonitorInfo` check; a window is full-screen when its frame covers all four monitor edges; own
process is excluded; monitor **index = `EnumDisplayMonitors` enumeration order** (the same order
`ScreenCaptureSourceFactory` uses to map index→HMONITOR via `Win32FullScreenDetector.GetMonitorHandle`).
`IFullScreenDetector` also exposes `GetDisplays()` (`DisplayInfo`: index/name/resolution/bounds/`IsPrimary`,
friendly name via `EnumDisplayDevices`) + `PrimaryMonitorIndex()` for the in-app "Capture Display"
submenu + `FullscreenMonitorChanged` event + `StartWatching()`/`StopWatching()`. At launch
`ReacquireScreenCaptures` heals backgrounds (`HealBackgrounds()`), then keys the Live background to the
full-screen game's
monitor; when **no game is detected**, `ResolveAutoCaptureKey()` returns `null` — stale `CaptureKey`
values are cleared, no capture session is started, and `Source.DisplaySource` falls back to
`_imageSource` (the static asset on the backdrop element). `StartWatching()` is called at the end of
`ReacquireScreenCaptures`. `OnFullscreenMonitorChanged` auto-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 + `Activated` handler) reads the current foreground directly as a manual
fallback. Deduplication: `_lastReportedMonitor` prevents redundant redesignates from rapid foreground
changes.
- **Capture (WinRT GraphicsCapture):** `ScreenCaptureFrameSource` creates a free-threaded
`Direct3D11CaptureFramePool` (2 buffers, `B8G8R8A8UIntNormalized`) + `GraphicsCaptureSession`; frames →
`SoftwareBitmap.CreateCopyFromSurfaceAsync` (alpha ignored) → `VideoFrame` (BGRA8), bytes read via
`WindowsRuntimeMarshal.TryGetDataUnsafe` (the same CsWinRT-safe read the webcam path uses) — the
`IMemoryBufferByteAccess` ComImport cast threw `Invalid cast` on **every frame** under CsWinRT, which
flooded `startup.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 as `SceneCompositor.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 cadence` telemetry).
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.LatestFrame` survives 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 to `startup.log` every 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:** `ScreenCaptureManager` mirrors `CameraManager` — refcounted by target key, one shared
`WriteableBitmap` per key, dispatcher-coalesced latest-frame copies, `PreviewBitmapChanged`/`CaptureFailed`
events, `ReleaseAllAsync` on re-designation. `ScreenCaptureSourceFactory.Resolve(key)` parses the key into
a source; `PickAsync()` shows the OS `GraphicsCapturePicker` ("Change Capture…", owner window set via the
`IInitializeWithWindow` ComImport) and returns a **transient** `picker:` 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`, an `EnumWindows` pass filtered like `Win32FullScreenDetector`:
visible, non-cloaked via `DWMWA_CLOAKED`, non-empty rect, other-process, titled). Picking one runs
`RedesignateBackgroundAsync("window:0x{hwnd:X}")` — the same full-bleed layer-0 capture path as the
game/desktop, keyed straight into `ScreenCaptureSourceFactory` (`window:` already resolved there).
**Session-scoped, never persisted:** `window:` (and `picker:`) keys are `IsTransientCaptureKey` —
`ReacquireScreenCaptures` strips 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:** `OnFullscreenMonitorChanged` skips re-designation while
`IsAliveWindowPin(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-runs `ReacquireScreenCaptures` → auto-fallback
(fullscreen game → desktop → static art). Seam: `IWindowEnumerator` + `WindowInfo`
(`Label = "Title — Process"`), `WindowEnumeratorOverride` static test seam mirrors
`CameraEnumeratorOverride`.
- **CsWinRT projection gaps hand-rolled:** `Windows.Graphics.Direct3D11.Direct3D11Helper` is not projected,
so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first
feature array) → QI `IDXGIDevice` → the WinRT interop export
`CreateDirect3D11DeviceFromDXGIDevice` → `MarshalInterface<IDirect3DDevice>.FromAbi` (one shared device per
process). Do **not** switch back to the QI-for-`IDirect3DDxgiInterfaceAccess` trick: the raw D3D11 device
no longer exposes that interface on newer Windows (verified E_NOINTERFACE on build 26200, hardware and
WARP alike) while `CreateDirect3D11DeviceFromDXGIDevice` keeps working.
`IInitializeWithWindow` are ComImports in `CaptureInterop.cs`. All WinRT projections were verified by
reflection against the built `Microsoft.Windows.SDK.NET.dll` before 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 (`FullscreenMonitorChanged` event + `RefreshBackdropAutoCapture`) re-target the backdrop, never
pace it. The lag is inherited: `Windows.Graphics.Capture` is 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 `_framePending`
gate drops frames during stalls, and the manager's `DispatcherPriority.Render` copies 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 —
`ShowPreviewPlaceholder` also checks `BackgroundImage` (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`/`ICameraFrameSource` interfaces. Tests inject fakes;
screen capture and background removal later feed the same seam.
- **CPU-first:** `MediaCaptureInitializationSettings { MemoryPreference = Cpu, StreamingCaptureMode = Video,
SharingMode = SharedReadOnly }`, frames pulled via
`CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8)` — the pipeline does any format
conversion, so every `FrameArrived` yields a ready BGRA8 `SoftwareBitmap` (bytes read via
`WindowsRuntimeMarshal.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 a `MediaFrameReader` (`StartAsync` → `OutputFormatNotSupported`; the MJPG pipeline exposes
NV12, not BGRA) — exactly what a browser grabbing the webcam produces. When the app can't re-negotiate
(our `SetMediaStreamPropertiesAsync` refuses 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 (see `MyMistakes.md` → WINRT RESOURCE-ALLOCATION RECIPE). Anything not
Bgra8 is converted in `OnFrameArrived`, 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) or `VideoRecord`, not blindly the first preview source. If a camera exposes neither, the
failure names the device and the stream types it *does* expose. `SharedReadOnly` lets the capture
coexist with other apps that share the camera.
- **Known failure: NVIDIA Broadcast** — it opens the physical webcam exclusively, so `InitializeAsync`
fails 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 (Logitech `VID_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). `EnableWindowsTargeting` keeps WSL builds working.
- **One webcam, many scenes (schema v3) — app-level default (2026-09-17):** a singleton `Webcam`
row holds the identity (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig`
(position/size/clip/mirror/border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at
most once, the webcam (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager`
refcounts capture sessions by `DeviceId` (a session starts at `RefCount = 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 via
`SwapWebcamIdentityAsync` (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 with
`WebcamAddToolTip` = "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; `_webcam` is 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 after `LoadLayout`, stored as `WebcamStartupValidationTask` so tests await it) enumerates the
OS once and tri-states on the count: **0** → app runs on, layer inactive, no alarm; **1** → attempt
`CameraManager.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 leaves
`RefCount = 1` and 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 (`CanAddWebcam` true); an already-loaded identity is never
overwritten. Test seams mirror `LayoutPathOverride`:
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
drive 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 — `CameraConflictProbe` reads only running process names, no device
handles; contention symptoms fit shared-mode/bandwidth just as well (see `MyMistakes.md`).
The **YouTube Chat layer follows the same one-per-layout rule** (TASK 27):
`CanAddYouTubeChat` greys out the (+) item while any scene carries a ChatBox source (raised on
staging + elements change; `AddSource` refuses a second as defense in depth). Chat layers created
before the "YouTube Chat" default still carried the legacy name "Chat" (commit `65641d8` era) —
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 public `RectWidth`/`RectHeight` before 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-time
`HealLegacySquareRect` (load only) widens a pre-v4 `Traditional` config that ended up square to 16:9
(keeps height; Round and explicit rect dims are untouched).
- **Device swap / layout reload:** `ChangeWebcamAsync` (picker) and `ReacquireWebcam` (after load)
release the old device with `ReleaseAllAsync` — 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) — then `AcquireAsync` the new device once per config.
- **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread
at the device's frame size (first frame), forwarded to every `WebcamSceneConfig.VideoImageSource` via
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher
(at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
device never drowns the render thread.
- **Webcam added mid-session must get the live frames:** `PreviewBitmapChanged` fires **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; `AddWebcamToActiveSceneAsync` assigns it to the new config right before
`AcquireAsync`, and `ReacquireWebcam` re-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` `#RRGGBB` or `""`=none, `BorderOpacity`
0–1, `BorderWidth` 0–20, `BorderAnimation` `None|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 an `EllipseGeometry` clip
(`Center=0.5,0.5` `RadiusX/Y=0.5`), inside a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it
renders as a true circle (diameter = the shorter element dimension) instead of an oval stretched to
the element rect — the traditional `Image` keeps `UniformToFill` over the full rect. The clip is
geometry, **not an `ImageBrush`**: a brush re-rasterizes the frequently-updated `WriteableBitmap` per
frame on the render thread, which is what made the live webcam crawl while round. The Round border is
a centered `Ellipse` at `Width/Height = RoundBorderSize`.
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == 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).
`RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp.
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
the element — making the corner handle ungrabbable. The element Grid carries
`Background="Transparent"` (whole rect draggable; the empty canvas Grid uses the same trick for
right-click Show Webcam) and the `SelectionOverlay` (dashed border + corner dot) is
`IsHitTestVisible="False"` so it never intercepts the click. Two things make the webcam menu work:
(1) the `ContextMenu` pins its own `DataContext` to `PlacementTarget.DataContext` — a `ContextMenu`
isn't in the visual tree, so without it the Click-handler `DataContext:` patterns (and the
IsChecked/slider bindings) silently fail; (2) `Themes/Controls.xaml` ships a full dark `MenuItem`
template — `PART_Popup` (submenu popups), a popup `ItemsPresenter` (the Border Opacity/Thickness
sliders live in Items, so they render in a hover flyout), a `✓` checkmark column, and a `›` arrow
driven by `HasItems`. An earlier bare `Border + Header` template dropped all three: submenus never
opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the
Click handlers were fine.
- **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 own
`tools/selfie-seg/convert_tflite_to_onnx.py` (the graph ends in MediaPipe's custom op
`Convolution2DTransposeBias`, 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.py` feeds the same frame to MediaPipe's `ImageSegmenter` (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 and therefore **licence-incompatible since the app went
proprietary (2026-10-30)** — none of its code is used, and none may be borrowed into this
codebase without a licence ruling first. Google's model is the provenance that matters.
- **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.onnx` is an **EmbeddedResource** (in git,
inside the exe — nothing to lose next to it, nothing fetched at runtime). Runtime =
`Microsoft.ML.OnnxRuntime` 1.31.0 (MIT), CPU execution provider, 2 intra-op threads. Note the native
`onnxruntime.dll` is ~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/`):** `IPersonSegmenter` seam (`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 on `WebcamSceneConfig`
(`BackgroundEffect TEXT DEFAULT 'None'`, `BackgroundStrength REAL DEFAULT 0.5`, no version bump —
the TASK 47 convention). `RemoveBackground`/`BlurBackground` are 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 by `WebcamEffectKey(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's `VideoImageSource` at its variant's bitmap; it re-runs on load, add, remove, device swap,
and any `BackgroundEffect`/`BackgroundStrength` change (configs are watched via `PropertyChanged`,
which also `ScheduleSave`s). 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 (`EffectPreviewBitmapChanged` on 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:
`MaskSmoother` blends each raw mask with the previous smoothed one — **0.75 × new + 0.25 × previous**
(`CurrentWeight`, internal const). State is **per `CameraSession`** (one per-camera worker), NOT in
the segmenter — the `OnnxPersonSegmenter` is 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-tests
`temporal_smooth_factor` at 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 the `Blur` bullet 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. `CameraManager` logs the real
average once per session after 120 processed frames (`startup.log`: "background effects avg …").
`MainViewModel.PersonSegmenterOverride` is 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 — two `PillToggle` switches, Remove and
Blur, bound to `RemoveBackground`/`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
image `mozart_square.jpg`, public-domain painting, in `ytLive.Tests/Fixtures/`). `CameraManagerTests`
— the ONE integration test for the jitter fix (a `SteppingMaskSegmenter` whose 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_strong` pins 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:
- `WebcamSceneConfig` carries the vertical placement (`VerticalX/Y/Width/Height/VerticalClipShape`
+ `VerticalRectWidth/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 and
`PreviewPane.xaml` are 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/Height` became **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`](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`](environment.md) — **Tests** (hermetic real-window rules camera tests must obey) and **Run** (launch/take procedure, the Release-only rule for measurements).