Zoom: fix preview zoom box + shape-aspect anchoring (Unit: zoom box visuals)

Right-click in the live preview now procs a box that is actually visible and
populated with the zoomed region, and each Zoom View Shape sculpts the box per
spec. OBS-style derivative: magnifier box shows the composited region and dims
the surroundings (same pattern as OBS "zoom" / preview region highlights).

Root causes of the invisible box (two WPF traps, recorded in MyMistakes.md):
- Empty <VisualBrush Stretch="None"/> used as an OpacityMask paints nothing,
  so the mask alpha is 0 and the WHOLE box (border included) went transparent.
- ZoomBox lived inside CanvasGrid while its VisualBrush sampled CanvasGrid — a
  circular reference WPF renders as nothing. Hoisted ZoomBox to a sibling of
  CanvasGrid inside a new PreviewCanvas wrapper (same 1920x1080 space).

Shape semantics (MainViewModel.ActivateZoom):
- Circle: square, click point = center.
- VerticalRectangle (phone): tall 9:16, click point = top-left corner.
- HorizontalRectangle (tablet): wide 16:9, click point = top-left corner.
- Boxes stay fully inside 1920x1080 via Math.Clamp.

UI: ZoomRectOutline/ZoomCircleOutline shapes replace the square Border;
circle clips the box to an EllipseGeometry; brush Visual released when inactive.
Zoom View Shape dropdown now uses the standard YtComboBox style.

Test (Good Dog rule, one integration test): ZoomBoxInteractionTests verifies
aspect/anchoring/clamp per shape and the matching outline+clip in the real
MainWindow. Full suite 404 passed, 0 warnings.

Memory: HANDOFF.md zoom state updated; WPF VisualBrush traps appended to
MyMistakes.md.
This commit is contained in:
2026-10-07 20:46:45 -07:00
parent 6b6284d05c
commit 966e718f1e
13 changed files with 530 additions and 567 deletions
+9 -2
View File
@@ -1,7 +1,8 @@
<UserControl x:Class="ytLive.Controls.OverlayHost"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers">
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:Models="clr-namespace:ytLive.Models">
<Grid Grid.RowSpan="3"
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
@@ -42,12 +43,18 @@
<TextBox Style="{StaticResource YtTextBox}"
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
<StackPanel Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
<TextBlock Text="Zoom View Shape" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<ComboBox Style="{StaticResource YtComboBox}" SelectedValue="{Binding ZoomShape}" SelectedValuePath="Tag">
<ComboBoxItem Content="Circle" Tag="{x:Static Models:ZoomShape.Circle}"/>
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static Models:ZoomShape.VerticalRectangle}"/>
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static Models:ZoomShape.HorizontalRectangle}"/>
</ComboBox>
</StackPanel>
<!-- Report Bug -->
+25 -5
View File
@@ -14,9 +14,18 @@
<Grid x:Name="PreviewGrid" Grid.Row="0"
PreviewMouseLeftButtonDown="Preview_MouseLeftButtonDown"
PreviewMouseMove="Preview_MouseMove"
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp">
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp"
PreviewMouseRightButtonDown="Preview_MouseRightButtonDown"
PreviewMouseRightButtonUp="Preview_MouseRightButtonUp">
<Viewbox Stretch="Uniform">
<Canvas x:Name="PreviewCanvas" Width="1920" Height="1080" ClipToBounds="True">
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
<Grid.RenderTransform>
<TransformGroup>
<ScaleTransform x:Name="ZoomScale"/>
<TranslateTransform x:Name="ZoomTranslate"/>
</TransformGroup>
</Grid.RenderTransform>
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Show Desktop" IsCheckable="True"
@@ -388,11 +397,22 @@
Margin="8,0,0,0"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Canvas>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Canvas>
</Grid>
<Grid x:Name="ZoomBox" IsHitTestVisible="False"
Canvas.Left="{Binding ZoomState.X}" Canvas.Top="{Binding ZoomState.Y}"
Width="{Binding ZoomState.Width}" Height="{Binding ZoomState.Height}"
Visibility="{Binding IsZoomActive, Converter={StaticResource BoolToVis}}">
<Rectangle x:Name="ZoomRectOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent"/>
<Ellipse x:Name="ZoomCircleOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent" Visibility="Collapsed"/>
<Grid.Background>
<VisualBrush x:Name="ZoomVisualBrush" ViewboxUnits="Absolute"/>
</Grid.Background>
</Grid>
</Canvas>
</Viewbox>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
Background="#CCe94560" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"
+119 -6
View File
@@ -17,6 +17,7 @@ public partial class PreviewPane : UserControl
{
private bool _isDraggingOverlay;
private bool _isResizing;
private bool _isZoomDragging;
private Point _grabOffset;
private double _resizeAspect;
@@ -29,8 +30,30 @@ public partial class PreviewPane : UserControl
public PreviewPane()
{
InitializeComponent();
DataContextChanged += PreviewPane_DataContextChanged;
}
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is MainViewModel oldVm)
oldVm.PropertyChanged -= ViewModel_PropertyChanged;
if (e.NewValue is MainViewModel newVm)
{
newVm.PropertyChanged += ViewModel_PropertyChanged;
UpdateZoomBox();
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape))
{
UpdateZoomBox();
}
}
// Static images and the webcam share the move/resize/selection behavior.
public static bool IsDraggableElement(SceneElement element)
=> element.IsDraggable;
@@ -387,12 +410,29 @@ public partial class PreviewPane : UserControl
private void Preview_MouseMove(object sender, MouseEventArgs e)
{
var vm = DataContext as MainViewModel;
if (vm is null || !vm.IsEditMode) return;
if (!_isDraggingOverlay && !_isResizing) return;
if (vm is null) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
if (e.LeftButton == MouseButtonState.Pressed && vm.IsZoomActive)
{
if (!_isZoomDragging)
{
vm.StartZoomMove(p.X, p.Y);
_isZoomDragging = true;
grid.CaptureMouse();
}
else
{
vm.UpdateZoomMove(p.X, p.Y);
}
return;
}
if (!vm.IsEditMode) return;
if (!_isDraggingOverlay && !_isResizing) return;
var selected = vm.SelectedElement;
if (selected == null)
{
@@ -429,14 +469,60 @@ public partial class PreviewPane : UserControl
}
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> EndPreviewDrag((Grid)sender);
{
if (_isZoomDragging && DataContext is MainViewModel vm)
{
vm.EndZoomMove();
_isZoomDragging = false;
((Grid)sender).ReleaseMouseCapture();
e.Handled = true;
return;
}
EndPreviewDrag((Grid)sender);
}
private void Preview_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
var vm = DataContext as MainViewModel;
if (vm == null) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
if ((vm.IsLive || vm.IsRecording) && !vm.IsEditMode)
{
if (vm.IsZoomActive)
{
vm.DismissZoom();
e.Handled = true;
return;
}
vm.ActivateZoom(p.X, p.Y);
e.Handled = true;
return;
}
vm.ToggleZoom();
e.Handled = true;
} private void Preview_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (_isZoomDragging)
{
var grid = (Grid)sender;
grid.ReleaseMouseCapture();
_isZoomDragging = false;
if (DataContext is MainViewModel vm) vm.EndZoomMove();
e.Handled = true;
return;
}
}
private void EndPreviewDrag(Grid grid)
{
if (_isDraggingOverlay || _isResizing)
if (_isDraggingOverlay || _isResizing || _isZoomDragging)
grid.ReleaseMouseCapture();
_isDraggingOverlay = false;
_isResizing = false;
_isZoomDragging = false;
if (DataContext is MainViewModel vm) vm.EndZoomMove();
}
private bool HitHandle(Point mouseScreen, SceneElement element)
@@ -461,4 +547,31 @@ public partial class PreviewPane : UserControl
}
return null;
}
}
private void UpdateZoomBox()
{
if (DataContext is not MainViewModel vm) return;
if (vm.IsZoomActive && ZoomVisualBrush != null)
{
ZoomVisualBrush.Visual = CanvasGrid;
ZoomVisualBrush.Viewbox = new Rect(vm.ZoomState.X, vm.ZoomState.Y, vm.ZoomState.Width, vm.ZoomState.Height);
ZoomVisualBrush.ViewboxUnits = BrushMappingMode.Absolute;
if (vm.ZoomShape == Models.ZoomShape.Circle)
{
ZoomCircleOutline.Visibility = Visibility.Visible;
ZoomRectOutline.Visibility = Visibility.Collapsed;
ZoomBox.Clip = new EllipseGeometry(new Rect(0, 0, vm.ZoomState.Width, vm.ZoomState.Height));
}
else
{
ZoomCircleOutline.Visibility = Visibility.Collapsed;
ZoomRectOutline.Visibility = Visibility.Visible;
ZoomBox.Clip = null;
}
}
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, CanvasGrid))
{
ZoomVisualBrush.Visual = null;
}
}
}
+36 -542
View File
@@ -1,542 +1,36 @@
# HANDOFF — current state
**Branch:** `main` (pre-1.0, no feature branches — creator ruling 2026-08-24).
**Pushed 2026-10-04:** `origin/main` = `048cab7`. **NOT PUSHED since — local `main` is ahead and the
tree is clean.** The stale-push-state failure `MyMistakes.md` warns about has now bitten
twice, so the rule stands: **check `git rev-list --count origin/main..main` before trusting any line
here about push state.**
Unpushed: `5ad0106` broadcast diagnostic → `74da99c` + `0fb2f9c` privacy/Test rulings →
`8fbc370` diagnostic parse fix → `07a088a` **the explicit-bind fix** → `509ef36` window placement →
`b2f2197` handoff → `UNIT 0` frame-cadence measurement.
## 🚧 IN FLIGHT 2026-10-05 — UNITS 4+5 landed: the render-budget work (needs a creator measurement)
**The 141ms → 33ms mystery is SOLVED, and it was geometry, not the build.** The opaque-backdrop 1:1
row walk only engages when the source rect is **exactly** 1920×1080. On a 21:9 monitor the source
rect shrinks to 1920×803 ("fit", not "fill"), so every render paid a ~2M-byte opaque-black prefill
plus a ~1.54M-pixel per-pixel alpha-branch resample instead of one `BlockCopy`. 2560×1440 fits to
1920×1080 exactly and takes the fast path. **Debug-vs-Release was NOT the cause** — today's Debug
build with the game running on 16:9 measured 33ms average. Every recorded A/B matches.
**Shipped (all pixel-identical, all gated):**
- **Unit 4** — opaque 1:1 fast paths widened to the whole-canvas case; **prefill only the uncovered
letterbox rows**; a **per-column LUT** for the scaled blit (source column + 8.8 weights computed
once per column, not a divide per pixel). Commits `0464593`, `08290ec`, `77d5120`, `dc13438`.
- **Unit 5** — **`Helpers/StripPool.cs`**: one composite splits into four horizontal row strips
across four DEDICATED `BelowNormal` background threads (the caller takes a strip too; inline below
64 rows). ⛔ **Do not "simplify" this back to `Parallel.For` with a thread-priority poke** — the
pool is shared, the demotion leaks into unrelated work, and it broke an audio test for real. Two
sources + the failure are recorded in `MyMistakes.md`; the design note is in `ai.md` →
"Render budget". Commits `623d77e`, `3901cb8`, + the strip-pool commit.
- Gate test `SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical` — 8 renders of a
multi-layer scene must be byte-for-byte identical, and must differ from a backdrop-only render so
it cannot pass vacuously. It caught a loop-variable capture that crashed the test host on run 1.
**⏭ NEXT STEP — one Release recording, 60s, Live scene, Division 2 running.** This is the
measurement that decides whether Unit 5 was necessary. Read the `FramePump stats` line (it now
carries `social`/`ticker`/`flash`/`acq` per-stage timings and the pump-start line names the scene +
compositor rect):
- **avg render ≤12ms** → Units 4+5 did it; go straight to **Unit 6** (encoder backpressure:
`worst submit` has hit 53ms with ~357 cumulative drops — real frame loss on top of the padding).
- **still >16.67ms** → the remaining cost is NOT the blit loops; read the per-stage timings before
touching anything else (do not guess — that is exactly the UNIT 0 lesson).
- **Under ~30ms on 16:9 but 140ms on 21:9** → geometry is still costing us; the fix is the tier
source-rect, not the compositor.
## 🚨 ANSWERED 2026-10-05 — the judder is the COMPOSITOR, not the camera. Unit 1 is the wrong next move.
Take `ty-20261005-1201-556-0001.mp4` (75.3s, 1920×1080, 60fps) + `startup.log`, read together:
| Counter | Value | Reading |
|---|---|---|
| `Camera cadence … arrivals` | **30.0/s, 0 gaps ≥50ms**, avg 33.4ms | the camera is **perfect**. Not the culprit. |
| `Camera cadence … camera` (device stamps) | 30.0/s, identical | the USB link and driver are clean too |
| `FramePump … from N renders` | **34 renders per 5s = 6.8/s**, avg render **141ms**, worst 294ms | one full composite takes longer than four camera frames |
| `webcam …` on the pump's line | 13.0/s, 34 gaps ≥50ms | only 13 of the 30 delivered frames ever reached a composite |
| the recording itself | **6.8 distinct webcam images/s, 88.7% duplicates, ZERO arrival hitches** | the judder is *only* us repeating a frame — nothing arrives late |
**⇒ The camera delivers all 30 of its frames, evenly. The compositor can only draw ~7 composites a
second, so the 60fps container is 89% repeats of a face held for 140ms.** That is exactly what a
lipreader experiences as "cutting out".
**⛔ Do NOT do Unit 1 (720p / exclusive lock) as the judder fix — it is the wrong lever.** 720p would
triple the bytes the compositor stretches per render on a path that is already 4.4× over its budget.
The exclusive lock is still worth having **for sharpness** (the C920 is pinned at 640×480 YUY2 and
the Chat scene upscales that ~2.1×), but it must be scheduled as a quality win, not a smoothness win,
and it should land **after** the compositor work — otherwise it makes the judder measurably worse.
**The number that needs explaining:** the 10:01 take hit **25.6** distinct images/s; this one hit
**6.8**. Same scene shape both times (`full-render split=0 elements=6 dynamic=5`), same camera, same
640×480. Composites went **29–34ms at 10:03 → 143–155ms at 12:01**, a 4.5× regression between
sessions with nothing in the log explaining it. `resolve` is 0.1ms, so the cost is pure pixel work,
not frame lookup. **That regression is the next thing to find, and the log cannot say what changed** —
no scene dump is written at stream start. ⛔ Do not guess at it.
**Cheapest next step, and it is a measurement not a fix:** log the per-element composite breakdown
(size + ms for each of the 6 elements) plus the scene contents at pump start. One run names which
element eats 140ms and whether the 10:03→12:01 regression was a scene change or something else.
Everything else in the compositor is guesswork until that exists.
**Also worth knowing (a counter subtlety, so nobody re-derives it):** the pump's `webcam` line reads
13/s while the file shows 6.8/s. Not a discrepancy — the counter stamps a frame when the RESOLVER
first sees it, and one composite takes 141ms, so ~4 camera frames arrive during it: the signature
pass records one epoch and the actual draw uses a later one. **The file measurement is the truth;
the pump counter overcounts by design** (it is a pipeline-health signal, not a frame-delivery metric).
## 🚧 IN FLIGHT 2026-10-05 — UNIT 0 shipped: a dropped webcam frame is finally measurable
**The measurement the map never had.** Take `ty-20261005-1001-821-0001.mp4` (85.6s, 1920×1080,
H.264 NVENC, 5124 frames @60) holds **25.6 distinct webcam images/s** in its lower-right region
against the camera's 30fps: 1,153 normal 33ms intervals, **281 gaps of 50–100ms**, 96 bunched 17ms
gaps. One hitch in five — the judder a DHH creator lipreads against, invisible in a file that muxes
flawlessly. Measured with `signalstats`/YDIF over `crop=480:270:1400:780`; the bundled ffmpeg has no
`mpdecimate`. ⛔ The old "webcam renders at ~18fps" claim was wrong (different session, counted all
newly rendered layers) — corrected in `ai.md`.
**Shipped:** `Helpers/FrameCadence.cs` (pure counter: frames, gaps, hitches ≥50ms, longest/avg), fed
from three points — `MediaCaptureFrameSource` logs `Camera cadence WxH: arrivals … | camera …`
(our delivery vs the device's own `SystemRelativeTime`), and `FramePump` logs distinct webcam
`Epoch`s that reached a composite. Full detail in `ai.md` → "Frame cadence".
**Also fixed, and it was a real measurement bug:** every pump average was divided by the number of
frames **submitted**, which deadline padding always fills to the full 60fps target — so a pump fed
one webcam frame per 200ms logged "300/300 frames, avg render 12.4ms". Render/resolve/wait now divide
by `statRenders` and the line reads `300/300 frames per 5s from 60 renders`. That pair IS the
duplicate-on-lag ratio. Test: `FramePumpTests.Stats_ReportRealRendersAndDistinctWebcamFrames_…`.
**⏭ NEXT STEP — needs the creator, one short recording (60s is plenty):** record with the webcam
visible and paste nothing — I read `%APPDATA%\ytLlive\startup.log` for the `Camera cadence` and
`FramePump stats` lines. Reading them together names the culprit: camera steady + arrivals ragged =
we drop it (reader/compositor); ragged in both = device/USB/driver; pump `webcam` below `arrivals`
= the compositor missed frames the reader already had. **Then Unit 1** (exclusive lock + 720p, see
below) is judged against those numbers.
**Agreed but NOT yet built (decided 2026-10-05, so a later session does not re-derive it):**
- **Unit 1 — exclusive camera lock + 720p on the camera the creator picked.** Open with
`MediaCaptureSharingMode.ExclusiveControl`; if init is refused, reopen the **SAME camera** in
`SharedReadOnly`. Target MJPG ≥30fps up to 1280×720, ranked by frame rate then size (this also fixes
the existing candidate-ordering bug). **The creator REVERSED the ban on taking an exclusive camera
lock** — that ban was never written into the map, which is why the reversal must be recorded this
time. Accepted trade-off: an app needing camera *control* that opens it after us will fail.
- ✅ **API probe DONE 2026-10-05 against the actual 19041.57 projection** (a throwaway
compile-probe file, since deleted — do not re-derive, and note the build cost of finding out):
`MediaCaptureSharingMode.ExclusiveControl` **is** projected, so the old comment in
`MediaCaptureFrameSource.cs` ("Exclusive is not projected by the 19041 SDK") is wrong and must be
corrected. `Exclusive` and `SharedReadOnly` are the **only two** `MediaCaptureSharingMode` values.
`MediaFrameSource.SupportedFormats` and `CurrentFormat` exist.
⛔ **`MediaFrameSource.SetFormatAsync(MediaFrameFormat)` is a DEAD END here** — it takes a
`MediaFrameFormat`, and that type has **no public constructor and no static factory** in the
projection (it is only obtainable from a live frame's `VideoMediaFrame.Format`). The two-argument
`(subtype, properties)` overload in Microsoft's docs does not exist here. So the format change must
go through the `VideoDeviceController.SetMediaStreamPropertiesAsync` path the code ALREADY has —
it is only refused because `SharingMode` is `SharedReadOnly`. Under an exclusive lock the existing
negotiation should simply start working, which is a much smaller change than planned.
- **Remaining unknown (needs a real device, not research):** whether `SetMediaStreamPropertiesAsync`
actually succeeds under `ExclusiveControl` on this C920. The `MediaFrameSource.SupportedFormats`
route is the fallback probe if it refuses — enumerate, and log what the device offers before
assuming anything.
- **Unit 2 — blinking "SD" badge in the preview only**, when a better format was available and we did
not get it. Top-right corner of the webcam, or the 1:30 point ON THE RIM for a round clip (a round
webcam has no corner); outside the mirror transform. ~1Hz opacity blink (under the 3/sec
photosensitivity limit), tooltip on hover only, **must never appear in the recording or stream**
(the compositor draws from camera frames, not the WPF tree). Style lives in `Themes/Controls.xaml`
— a DataTrigger storyboard cannot be stopped inside a DataTemplate (`PreviewPane.xaml:168-171`).
- ⛔ **NO fallback-camera setting, ever** — creator ruling: *"no fallback - that's bypassing the
user's explicit choice."* Picking a vcam is a user action in the camera picker. Corollary, also
recorded in `ai.md`: **Windows Studio Effects is NOT a vcam** — it runs inside the physical camera's
driver and never appears as a selectable device. Only Broadcast / XSplit VCam install one.
- ⛔ **Background removal: permanently out.** Creator confirmed the existing note: *"no - not doing
it. the note is correct and accurate."* `ai.md` said "milestone 2", which was stale — now corrected.
## 🚨 FIXED 2026-10-04 — broadcasts never went live because they were never BOUND
**Root cause, proven by the diagnostic rather than inferred:** `contentDetails.boundStreamId` on
`liveBroadcasts.insert` **does not bind the broadcast.** The log line read
`lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING |
streamStatus=active`.
`created` is Google's *"incomplete settings"* state, so `enableAutoStart` had no bound stream to fire
from, and close-out 403'd `invalidTransition` because `created → complete` is not a legal edge. The
reason it survived a week: **a reusable stream accepts bytes for a broadcast that is not watching it**,
so a perfectly healthy 99 MB RTMP session is exactly what an unbound broadcast looks like. Google's
migration guide states the rule outright — *"a liveBroadcast must be bound to exactly one stream
before the live broadcast can actually start"* — and it was in the docs read during the original
design.
Shipped `07a088a`: `BindBroadcastAsync` → `POST liveBroadcasts/bind?part=id&id&streamId` (no body),
called right after `CreateBroadcast` and before the pump; failure is fatal to the session because an
unbound broadcast can never go live. `boundStreamId` and `CreateBroadcast`'s `streamId` parameter both
removed. The old test asserting insert-time binding **laundered a guess into a guarantee** and was
replaced. Also corrected the TASK 44 claim that `liveChatId` only appears once live — it is present
while lifecycle is `created`; that claim came from reading a sample's active-status guard as a spec.
**⏭ NEXT STEP — needs the creator, one 60-second Test:** confirm `Broadcast bound:` appears and
lifecycle walks `created → ready → live`. If it binds but still won't auto-start, the *next* suspect
is an explicit `transition`, not the binding — do not re-open the binding question without log proof.
**Coverage gap:** no automated test asserts `PrepareAndStartLiveAsync` actually *calls* bind — the
service method is tested in isolation, but the wiring in the ViewModel is not. Acceptable only
because the end-to-end symptom was the whole point of the diagnostic; revisit if the wiring is ever
touched again.
## 🚨 FIXED 2026-09-27 — "Change Account" no longer signs you out before the browser opens
**Reported as:** *"I clicked on my signed-in account pic, then clicked sign-in to another account. It
opened the browser page to login under a different account and I canceled the request. But when
returned to the app, I see logged out — and should not have happened! Cannot sign-in because the
callback url was not released."* Also reported as hitting **both** running instances.
**Two independent defects, and the second one was masking a race:**
1. **The sign-out ran first.** `ChangeAccountAsync` was `await SignOutYouTubeAsync(); await
SignInAsync();`. `SignOutYouTubeAsync` → `TokenStore.Clear()` → **deletes** `ytLlive.auth`. So the
working account's token was destroyed *before the browser even opened*; cancelling the chooser left
nothing. Now `ChangeAccountAsync` is just `await SignInAsync()` — success overwrites the token via
the `sessionChanged` hook, cancel/failure keeps the current session. No sign-out is needed at all.
2. **The retry couldn't rebind the callback port.** Abandoning the browser gives the app no signal, so
the attempt held `localhost:8765` for its full 5-minute timeout. A new `AuthenticateAsync` now
cancels and awaits the abandoned attempt before binding, **serialised by a `SemaphoreSlim`**
(`_authGate`) — without the gate, the takeover window let a third caller see a free port and race
to bind it, i.e. the takeover reproduced the original failure. My own test caught this.
**The cross-instance claim was NOT the cause** and is still open as a *separate* question: no code path
can sign out a second instance (`TokenStore` is per-`InstanceProfile.DataRoot`, `Clear()` is a local
file delete with no server-side revoke, and no second instance had a token at inspection time). Verify
both processes carried distinct `YTLIVE_INSTANCE` values and ran Debug builds before chasing it.
Test: `YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort`. Full suite
**370/370**, 0 warnings (build `ytLive.Tests.csproj`, not just the app project, or VSTest runs a stale
test DLL). Added an `openBrowser` ctor seam to `YouTubeAuthService` so tests never launch a real
browser. **Coverage gap:** the *ordering* fix (defect 1) has no automated test — `_youtubeAuth` is
hardcoded in `MainViewModel` and `ChangeAccountAsync` is private, so a real test would shell out to
the browser. Wiring an auth seam into `MainViewModel` is a deliberate follow-up, not a bug-fix extra.
## 🗄 PARKED — Google OAuth verification: attempted, hit a Google-side bug, tabled by ruling
**Creator ruling: unimportant for where we currently sit.** Verification is *not* a live blocker and
**must not be re-attempted, re-derived, or re-researched** without a new ruling. The 2026-09-27 entry
that preceded this one wrongly sat at `⏭ NEXT STEP` and framed it as the top priority — that was the
plan going in, not the outcome, and the outcome was never written down. Corrected 2026-09-28.
**What happened:** the verification attempt was made and failed on a bug **on Google's side** — not
something we can fix, work around, or retry our way past. ⛔ **The exact mechanism was never recorded
and is deliberately left blank** rather than reconstructed from memory: a confidently wrong note here
is worse than none, because the next session would trust it. Do not go looking for the cause.
**Owning account (creator-confirmed): `llamachile.shop@gmail.com` ("gramps").** Cloud project number
`198374416003` (from the ClientId) — banked in `ai.md:233`, **do not re-hunt it.** The one-hop console
link is `https://console.cloud.google.com/apis/credentials?project=198374416003` (picker defaults to
**"Recent"** — switch to **"All"**). ⛔ Not the same account as the YouTube channel signed into the app.
**What tabling costs, stated honestly:** if the consent screen is still in **Testing**, the 100-user
cap and the **7-day refresh-token expiry** both stand — that expiry is why the creator kept having to
re-consent. Parking verification does not fix that; it accepts it for the solo-use phase. It becomes
real work the moment a *second real user* needs to sign in, or at Store submission (TASK 48). The
sensitive-vs-restricted classification was never read and is moot until then.
**One of these two survives the tabling, and the other was a bad idea (corrected 2026-09-28):**
- ✅ **DONE — `Services/YouTubeAuthService.cs` no longer requests `auth/youtube.force-ssl`.**
It is **redundant**: its grant is *"see, edit, and permanently delete your YouTube videos,
ratings, comments and captions"*, a strict subset of `auth/youtube`'s *"Manage your YouTube
account"*, which is the scope we keep. Google's own `liveBroadcasts.insert` reference lists
`auth/youtube` as an accepted **alternative** to `force-ssl`, so every endpoint this app calls
(`channels`, `liveBroadcasts`, `liveStreams`, `liveChat`, `playlistItems`) is covered.
Two consent lines became one. **No forced re-consent** — a refresh token keeps the scopes it
was originally granted, so existing sessions are untouched; the reduced set applies at the
next fresh sign-in. Removing a scope cannot narrow capability when the one kept is broader.
- ❌ **NOT doing this, and the earlier note recommending it was wrong: moving
`playlistItems.list` off the user's session token.** It was written during the verification
hunt on the theory that "public data should not ride on user credentials". Walking the code
kills it: `ChannelStatsService` is built as `new ChannelStatsService(_youtubeAuth)` with **one**
`HttpClient`, and `channels?mine=true` (same service) **genuinely needs the session**. To split
it you would need a second unauthenticated client **plus a Google API key**, because every
YouTube Data API request needs a credential — without a key the call 403s. So the "fix" means
**shipping a new extractable credential in the binary, subject to quota abuse, to replace a
token the app already holds.** It buys **zero** verification-surface reduction (the data is
public; no scope is involved) and zero correctness. Strictly worse. ⛔ Do not "fix" this later
without a new ruling.
## ✅ DECIDED 2026-09-27 — pricing: `$10/mo` subscription, or `$400` lifetime, **no annual**
**Creator's ruling, verbatim: *"Let's just go with $10 monthly recurring subscription for option
A and, for option B, a lifetime sub for $400. No annual."* ⛔ **No `.99` prices** (earlier
ruling: *"Let's stop with the x.99 stuff - I find that irritating. Just say: ten bucks a
month"*). This **supersedes** the 2026-09-21 one-time `$29 → $49` model and the old `$99/yr`
sub. No feature gating: free gets everything, the branding flash is the only paid delta.
**✅ Store revenue share VERIFIED — 15%, and subscriptions are NOT penalised.** Read from the
ADA v8.10 PDF itself (downloaded, text-extracted — not a search excerpt). §6(b)(i) charges
**15%** on "any Apps (and any In-App Products in such Apps) that are **not listed in Section
6(b)(iii)**"; 12% is Games-only; 30% covers Xbox console apps/games, Xbox non-subscription
IAP, and Windows 8/Phone 8. LlamaCasty is a Windows PC App — not a Game, not Xbox, not Win8 —
so **6(b)(i) governs both tiers.** The agreement's own changelog (**v8.0, Oct 26 2017**) says
it outright: *"implement the **85/15 revenue share for non-Game subscriptions**."*
⇒ **`$10/mo` nets `$8.50/mo` (~$102/yr). `$400` lifetime nets `$340`.** Also confirmed: the
15% applies *after* VAT/GST (Net Receipts), payouts are monthly above a **$50** threshold, and
no better small-indie rate was found in the standard terms.
**⛔ Two obligations §6(h) attaches to the recurring tier** (why lifetime is the hedge):
(1) we must **fulfil the subscription for the entire period as marketed**, and on breach
Microsoft may refund the customer *"the full amount, plus taxes... in Microsoft's sole
discretion"*; (2) **raising the price disables auto-renew** — so `$10` is effectively locked
for the product's life absent Microsoft's price-change process.
**⏳ Still open:** the `$50` subscriber→lifetime upgrade-discount SKU was discussed and is
**not approved** — do not declare it. Individual vs Company Partner Center account still
unanswered (get it right before enrolling).
## ✅ DECIDED 2026-09-27 — distribution is the Microsoft Store: MSIX + Store IAP
**Creator's criteria, verbatim: "zero headaches, minimal maintenance (for me) while still
providing accountability and a reasonably easy upgrade flow."** Route A is the only option
where all four are solved by handing the work to Microsoft rather than to a certificate
vendor: **$0/yr, no certificate, no HSM, no annual renewal, no SmartScreen ramp**, plus Store
auto-update, Store-side payments/entitlements/refunds/support, and Microsoft review as the
accountability layer. The rejected options and their reasons are in
`TASKS/research-store-certification.md` §3 — **read that before reopening the question, not
before re-deriving it.**
**The big consequence: the whole licensing backend is deleted.** `PolarLicenseService`,
`PolarLicense`, `MainViewModel.License.cs` (`PremiumUrl`, customer portal, the
`OfflineGracePeriod = 14 days` subscription-era artifact, renewal/lapse copy) and the wrong
"Polar unlocks alerts" string all become dead code. `IsPremium` comes from the **Store
entitlement** instead of an HTTP call — which also deletes the entire "network flaky → app
thinks I'm expired" bug class. **Velopack, the update URL and the DO droplet go too.**
**What does not change: the entitlement.** Free gets everything; the branding flash stays the
ONLY paid delta. Store IAP changes how `IsPremium` is *obtained*, never what it *gates*.
**⛔ The old "still open: the price" note is retired — the price is settled, see the top of
this file.** The old "~$69 floor" and "don't break the launch-price promise" notes refer to
the old Polar storefront and are **not** current.
### ✅ DONE 2026-09-28 — TASK 48 item 1: bundle ffmpeg (was the "first code unit")
The app no longer downloads an executable at runtime. `scripts/fetch-ffmpeg.sh` populates a
gitignored `ffmpeg/` from a **SHA-256-verified** pinned month-end BtbN artifact; `ytLive.csproj`
copies it to output *and publish* and **fails the build** if it's missing; `FfmpegLocator` lost its
downloader, its zip extraction and the `%APPDATA%\ytLlive\tools` cache, and now resolves
**bundle → PATH** with a loud, actionable failure. The pin that 404'd is gone, so the
2026-09-01 failure class cannot recur. Suite **379/379 at that point** (380 now — see Test
state); 16 locator facts including an
integration test that **executes the bundled binary** (which is what proves the sibling DLLs
resolve). `THIRD-PARTY-NOTICES.txt` corrected — it still named the *stale* tag
`autobuild-2026-08-09-13-03`, i.e. the one that 404'd.
**Fresh-clone cost, deliberately accepted:** `./scripts/fetch-ffmpeg.sh` once (~65 MB). 135 MB of
DLLs in git would tax every commit on this 9p tree; the URL + SHA-256 are the audit trail.
**Answering "other 3p binaries needing bundling?" — no, verified.** ffmpeg was the only
download-and-execute. `e_sqlite3.dll` + `WebView2Loader.dll` already ship via NuGet
(`runtimes\win-x64\native\`), OpenH264 is compiled *into* `avcodec-63.dll`, the rest are managed.
Two gaps remain that bundling cannot fix, and they are the next real risks: the **WebView2 Evergreen
runtime** is a machine prerequisite (item 2's manifest dependency + a graceful-failure path), and
**Velopack's updater downloads and executes**, so its teardown in step 3 is a compliance action.
### Deferred, not next: the packaging project + `Package.appxmanifest` (TASK 48 items 2–3)
**Creator ruling 2026-10-04: still in active development, so ship-time work waits and
queued features come first.** The unit is unchanged and ready when it is wanted:
Full trust, `webcam`/`microphone`, English only; Velopack deleted. Add the `DefaultRecordFolder()`
comment in the same unit (the mediumIL invariant below becomes load-bearing the moment the manifest
lands). Full-trust writes under a real package identity are still **unverified** — confirm on a
real packaged build before trusting them with recordings.
Carried over from the research unit, still open:
- **⛔ `Distribution.md:318` recommended a $400+/yr EV certificate for a SmartScreen bypass
Microsoft removed in March 2024.** Fixed then — had it shipped, it would have cost
$400+/yr to buy what $150 buys. Now moot anyway (MSIX is signed by Microsoft), but the
lesson in `MyMistakes.md` stands: a policy citation is a claim about **scope**, not just
text.
- **WebView2 Evergreen runtime** is a machine prerequisite the app will hard-fail without. The
manifest needs the framework dependency, and WebView2 init needs a graceful error instead of a
crash. New, found while bundling ffmpeg.
### ⛔ Two invariants that break silently if touched
- **Full trust, or recording breaks.** `DefaultRecordFolder()` writes to Downloads/MyVideos;
that passes through unvirtualized **only** at `mediumIL`. Flip the manifest to
`appContainer` and output vanishes with no error. A comment is owed there **when the
manifest lands** (TASK 48 item 6) — not before.
- **Chat is rendered, never stored.** That non-persistence half is the load-bearing part of
the 11.12 certification answer. No chat history, moderation log, analytics, or crash payload
may be added without re-arguing 11.12 first.
## 🚨 OPEN 2026-09-28 — the RTMP push never lands: recordings reach YouTube as EMPTY placeholders
**This is a live bug, not a queued feature**, and it reframes a complaint the map previously
treated as closed. It was found on 2026-09-28 while scoping TASK 50, and the handoff had no
entry for it at all — corrected here.
**What the creator observed:** the channel carries **20+ private videos, all content-less
placeholders** (creator-inspected, 2026-09-28). This is the real explanation of the
2026-09-26 report "I still cannot get private test recordings saved" — it was never true that
the app could not *find* an archived VOD. **No video content is ever produced on the channel.**
**Mechanism (verified against the code AND the channel):** `liveBroadcasts.insert` succeeds and
creates the placeholder; everything after it is what fails. Content only exists if all three of
these land, and at least one does not:
1. broadcast reaches **LIVE** via `liveBroadcasts/transition`,
2. ffmpeg actually pushes RTMP to the bound stream key,
3. the broadcast ends and YouTube transcodes it into a VOD.
`enableAutomaticArchiving` is **never set**, so YouTube's `true` default applies — the archiving
route exists but has nothing to archive. Broadcasts are inserted `private`
(`Services/YouTubeStreamService.cs:49`), so the orphans are not public, and the app has **no
delete path** — the creator clears each one by hand in Studio.
**✅ DIAGNOSTIC ANSWERED 2026-10-04 — ffmpeg reaches YouTube; the broadcast never goes LIVE.**
One test-mode push, read from `startup.log`. The three steps, resolved:
1. `new MediaFoundationReader`/RTMP path — **WORKS.** ffmpeg opened
`rtmp://a.rtmp.youtube.com/live2/4pew-…`, muxed **7.4 MB video + 46 KB audio**, and
finalised the FLV with no socket error. Step 2 is fine.
2. **The broadcast stays `ready` — this is the break.** `enableAutoStart = true` *is* sent
on insert (`Services/YouTubeStreamService.cs:73`), yet at close-out YouTube refused
`transition=complete` with **403 `Invalid transition`** — which is only legal from `live`.
So YouTube never flipped `ready → live`.
3. Never reached (nothing to archive because nothing went live).
**Two unknowns, and the honest state of each:**
- **The push ran only 8 seconds** (08:48:23.9 → 08:48:32.8). YouTube may simply need longer
to accept and validate the ingest before it auto-starts. Untested — do not assume.
- **The app cannot see this either way.** During a stream it polls **stream** health
(`liveStreams`) and never the **broadcast** status; `GetLifeCycleStatusAsync` is called
*only* from the end path (`YouTubeStreamService.cs:146`). So the log contains no evidence
either way, and `EndBroadcastAsync` skips only on `complete`/`revoked`, meaning it tries
`complete` against a `ready` broadcast and earns the 403.
**⇒ Next step, small and cheap:** log the broadcast's `lifeCycleStatus` while streaming
(reuse the existing health poll timer — no new loop), then run **one 60-second** test. That
single run answers it. Do not go hunting beyond it until it has.
**Sequencing constraint — do not flip the flag early.** `enableAutomaticArchiving: false` is the
correct end state (TASK 50 will upload the local file deliberately), but archiving is
**currently the only route a recording takes to YouTube** — flipping it before TASK 50 ships
would leave test streams with nothing at all and re-create the 2026-09-26 complaint in a worse
form. Ship the upload, flip the flag in the same task, never before.
Detail and the open 11.12 re-check: [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md).
## Landmines
- **Frame pump jitter — counters now honest, cause NOT yet named (2026-10-05).** The same test
stream logged **86 stalls per 5s** (`FramePump stall: iteration 40–50ms` against a 17ms budget)
while `FramePump stats` read 301/300 frames, 0 dropped. UNIT 0 removed the reason those two
numbers could disagree (the averages were diluted by padded submits) and added the counters that
separate reader from compositor — **but no one has read a post-UNIT-0 log yet.** A creator
recording is required first. Do not "fix" the pump before that reading.
- **Stale fact inside `TASKS.md` (left alone, flagged here per the Scope Lock).** The "1.0 gates"
section asserts "**TASK 36 is now shipped**", but the catalog row 36 reads **☐ Queued** and
`task-36-gold-pass.md` still shows items 1–6 unchecked. The line most likely means *item 2*
(branding flash goes live) shipped at `9761b1d`. **Needs a one-line fix, not a code change** —
flagging rather than fixing because it is not this unit's job.
- **A stale `ytLive.exe` (PID 2544) locked `bin/.../ytLive.exe`** and broke `dotnet run` with
MSB3027. Killed. **If the build fails to copy `ytLive.exe`, check for a running instance first.**
- **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` needs an
interactive desktop session.** As of 2026-10-05 it fails deterministically here (`ImgC` vs
`ImgA`), verified identical on a clean tree. **Run the suite with ytLive CLOSED** and expect this
one to fail in this shell. Not a regression — don't diagnose it from a WSL-shell failure.
- **`RealAppHost.RunAsync` exists for a reason** — a frame-pump test MUST `await` inside it. A
blocking wait occupies the one shared STA thread and **hangs the whole suite with no output.**
Always background a `vstest` run and poll the log; a foreground piped `vstest` returns nothing
in this shell even on success.
- **`GlobalHotkeys`: two instances registering the SAME global hotkey** — Windows refuses the
second. Left alone deliberately.
- **MSIX writes under a real package identity are unverified.** The docs say full trust passes
user-profile writes through, but confirm on a real packaged build before trusting it with
recordings (TASK 48 item 6).
## Test state
**402/402, 0 warnings, verified 2026-10-05** (395 + 6 `FrameCadenceTests` + 1 pump-stats integration
test + 1 strip-parallel gate). Re-verified after UNITS 4+5. Build the **test** project, not just the app project, or VSTest runs a stale DLL.
**Launch exe for the creator's test run: `bin/Debug/net8.0-windows10.0.19041.0/ytLive.exe`.**
**Confirmed environmental flake, NOT a regression (re-checked 2026-10-05, and it now fails
deterministically rather than 2-of-3):** `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_
PersistsTheReorder` fails with `Expected "ImgC", Actual "ImgA"` at `RealAppHost.Run` line 68. **It
fails identically on a stashed clean tree**, so it has nothing to do with UNIT 0 — it needs an
interactive desktop session this shell does not have (the mouse drag never lands). Do not chase it;
it is listed under Landmines as the known interactive-session test.
**⚠️ A game-playing session breaks the audio tests — know this before debugging it.** On
2026-10-05 `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` failed with
480/480 samples at `0.4` where `0` was expected, then passed in isolation minutes later. Cause: the
user was in a **Division 2 boss fight**, and that test captures **audio loopback** — the game's own
output landed in it. **An audio test failing during a gaming session is an environment fact, not a
regression.** Same bucket as the interactive-desktop flake above: re-run in a quiet session before
believing either. (It also briefly failed while I was probing `Parallel.For` thread priorities,
which sent me after the wrong culprit entirely — the game was the simpler explanation.)
**⚠️ New flake, do not chase it as a regression:** on the *first* full-suite run,
`AudioPipelineTests.MasterLimiter_CapsTheLiveMix_OnThePipe` failed with `Expected 0.891,
Actual 0` — a **zero**, not a slightly-off value, which is the signature of the pipe not being
fed yet rather than the limiter misbehaving. It passed 27/27 as a class run and 380/380 on an
immediate full-suite re-run. It reads the live mix **off the named pipe**, so it is a
whole-suite scheduling artifact of the same family as the `RealAppHost` STA landmine below —
**if it ever goes red, re-run before believing it.**
## Uncommitted / untracked
- `ffmpeg/` — the 9 bundled binaries (~135 MB), **gitignored by design**. Populated by
`scripts/fetch-ffmpeg.sh`. Do not commit it.
- `MARCOM.md`, `MONETIZATION.md` — both edited (privacy-copy guard; the Polar-obsolete banner
and the re-opened-price note) and both **gitignored by design** (confidential business
files, `.gitignore:10-11`). They stay local, as intended. Same for `CREDENTIALS.md` —
**never commit those.**
## Next
1. **~PUSH, then the creator's 60-second Test** — see the FIXED box above. Packaging is NOT next;
the creator's ruling 2026-10-04: still in active development, so ship-time work waits and queued
features come first.
2. **⏭ Read the Unit 4+5 Release recording (see the IN FLIGHT box at the top).** Then Unit 6
(encoder submit backpressure: `worst submit` 53ms, ~357 cumulative drops) if render is inside
budget; if render is still over, the per-stage timings name the stage — do not guess.
3. **Webcam cadence → Unit 1 (exclusive lock + 720p).** UNIT 0 is shipped and the counters are in
`startup.log`; the reading needs one 60-second creator recording (details + the exact
read-the-log-together table in the IN FLIGHT box above). Then Unit 1: exclusive camera lock +
720p, and Unit 2: the blinking SD badge. **Creator to-do, not yet queued: long-run
resource/consumption/recycling benchmarking** (how long the app stays healthy under full burden) —
record only, do not start it.
4. **The packaging project + `Package.appxmanifest`** (TASK 48 items 2–3) — the next code unit.
Full trust, `webcam`/`microphone`, English only; Velopack deleted. Add the
`DefaultRecordFolder()` comment in the same unit, and the WebView2 framework dependency +
graceful-failure path.
5. **TASK 50 — one-button "Publish this video"** (`TASKS/task-50-publish-recording.md`). Queued
behind the Store submission by its own precedence note ("MSIX first, publish second — unless
the creator reverses that"). Scoping is done; the build is not. ⛔ Do **not** flip
`enableAutomaticArchiving` until it ships. It also carries an **open 11.12 re-check**: a
publish feature makes the app *push* creator content to a third party, so the IARC answers
must not go to certification describing an app that cannot publish.
6. **Declare the IAP products in Partner Center** — the price is **settled** ($10/mo, $400
lifetime, no annual, 15% verified), so this is unblocked. **Blocks:** the Store listing and the
`OverlayHost.xaml` copy. ⛔ The Individual-vs-Company account question is still unanswered —
get it right before enrolling.
7. **Polar teardown** (TASK 48 item 0) — `PolarLicenseService`, `PolarLicense`,
`MainViewModel.License.cs`, the `OverlayHost.xaml` string, the `csproj`/`App.xaml.cs`
Velopack sites. `IsPremium` ← Store entitlement. **Do this as one unit** — a half-torn-down
licensing path is worse than either end state.
8. **Vertical recording verification** — the compositor tier is proven by
`Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop`; the *record* path has never
been run. `FramePump.cs:645` flags off-size tiers as a known follow-up.
7. **WACK + certification notes + the privacy policy** (TASK 48 items 4–5) — the policy must
state camera/mic is OS-gated, nothing is retained, and **nothing is fetched at runtime**
(now literally true — the ffmpeg bundle is what makes that sentence defensible).
8. **Post-session efficacy report** — roll up `CurrentHealth` (dropped frames, duration, health
message) when a stream or recording ends. `SessionTeardownTests` is the natural home.
9. **Measure whether the Windows camera toggle gates DShow** — gates all privacy-forward copy
(`MARCOM.md` guard). Not a certification risk; a trust risk.
10. **TASK 36:212 stale "shipped" line** — one-line fix, needs a hand.
**Dropped from the list** because the ruling made them moot: multi-instance's ffmpeg tools-dir
race (item 1 deleted the tools dir entirely), `scripts/publish.sh` + the `LlamaCasty.exe` rename
(the Store packages and delivers — *revisit only if we ever ship outside the Store*), and the
alert-gating copy (the wrong string dies with the Polar teardown in step 3).
**Everything else in the v1 queue:** stream resilience (32), bandwidth step-down (33), scheduled
streams (34), scene-linked audio (35), the master limiter (12), text source (3.16), reward
events (3.20), the media picker (21), webcam identity (9.5), settings units A/C/D (40), and the
defaults split (37). Ship-checklist items live in `TASKS.md` -> "1.0 gates".
# HANDOFF — 2026-10-07
## Branch
- main (no feature branch)
## Dirty files
- None (zoom box fix + test committed as a work unit this session).
## What just happened
- Fixed the zoom box: right-click in live preview now procs the selected Zoom View
Shape with correct anchoring and a visible, populated box (previously invisible —
empty OpacityMask made the whole box transparent and the VisualBrush pointed at the
CanvasGrid *ancestor*, a circular reference WPF renders as nothing).
- Zoom View Shape dropdown now uses the app's standard `YtComboBox` style.
- One integration test added (Good Dog Rule): `ZoomBoxInteractionTests` — circle is a
centered square, phone is a tall 9:16 rect and tablet a wide 16:9 rect, both anchored
top-left at the click, box stays inside 1920x1080 (clamp), and the UI shows the right
outline/clip per shape. Full suite: 404 passed, 0 warnings.
## Zoom feature state
- Models/ZoomState.cs + Models/ZoomShape.cs (Circle, VerticalRectangle, HorizontalRectangle)
- ViewModel: zoom box activation/move/dismiss; compositor output crops+scales the zoom region; dimming updates
- PreviewPane: zoom box is a *sibling* of CanvasGrid inside a new `PreviewCanvas` wrapper
(so the VisualBrush can sample CanvasGrid without a circular reference); shape-aware
outline (`ZoomRectOutline`/`ZoomCircleOutline`) + ellipse clip for Circle
- Shape semantics in `MainViewModel.ActivateZoom`: Circle = click point is center, w=h=min(maxW,maxH);
VerticalRectangle = 9:16 tall, HorizontalRectangle = 16:9 wide, both anchored top-left at the click; clamped to frame
- Settings (OverlayHost) Zoom View Shape persisted via LayoutStore
## Known issues / follow-ups
- Background toggle bug mentioned earlier: when clicking "Use default background" in layer properties, nothing happens. Need to investigate - binding exists (UseDefaultBackground), VM has handler that invalidates bake and updates. May need to look up Source background logic.
## Next step
- User runs the app live/test to eyeball the zoom box visuals (border shows, region content visible,
circle is round, phone/tablet anchored at the click point). Then background "Use default background"
toggle if still needed. Otherwise stand down.
+8
View File
@@ -0,0 +1,8 @@
namespace ytLive.Models;
public enum ZoomShape
{
Circle,
VerticalRectangle,
HorizontalRectangle
}
+20
View File
@@ -0,0 +1,20 @@
using System;
namespace ytLive.Models;
public class ZoomState
{
public bool IsActive { get; set; }
public double X { get; set; }
public double Y { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public bool IsMoving { get; set; }
public double GrabOffsetX { get; set; }
public double GrabOffsetY { get; set; }
public double Left => X;
public double Top => Y;
public double Right => X + Width;
public double Bottom => Y + Height;
}
+19
View File
@@ -1475,3 +1475,22 @@ a cycle debugging a test failure, ask what the machine was doing: an audio test
gaming session is an environment fact, not a regression.** (Same family as the known
`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` interactive-desktop
flake — both are in the "run it in a quiet session" bucket, and neither is a code defect.)
## 2026-10-07 — WPF VisualBrush: two silent "invisible box" traps
Working on the zoom-magnifier box in PreviewPane. Two gold-plated WPF gotchas that made the box
render NOTHING (no border, no content):
1. **An empty `<VisualBrush Stretch="None"/>` used as an `OpacityMask` paints nothing → the mask
alpha is 0 → the WHOLE element (border included) becomes transparent.** A VisualBrush without a
`Visual` is not "mask = none", it is "mask = fully transparent". An opacity mask wants a tile or a
visual source; if you meant "no mask", OMIT the Grid.OpacityMask element entirely.
2. **A `VisualBrush` whose `Visual` is an *ancestor* of the element it is painted on is a circular
reference — WPF renders nothing (no exception, no content).** The zoom box lived inside CanvasGrid
and the brush sampled CanvasGrid. Fix: hoist the box OUT so it becomes a *sibling* of the visual
being sampled (wrap CanvasGrid + ZoomBox in a container Canvas, coordinates unchanged since both
are in the same 1920x1080 space). Then `VisualBrush.Visual = CanvasGrid` samples cleanly and the
box magnifies the already-composited preview.
Release the retained visual when the box hides (`VisualBrush.Visual = null`) or the long-lived
source visual stays pinned for the app lifetime.
+2 -2
View File
@@ -283,7 +283,7 @@ public partial class LayoutStore : IDisposable
UpsertSetting("Window.State", state);
}
private string? GetSetting(string key)
public string? GetSetting(string key)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = $key;";
@@ -291,7 +291,7 @@ public partial class LayoutStore : IDisposable
return cmd.ExecuteScalar() as string;
}
private void UpsertSetting(string key, string value)
public void UpsertSetting(string key, string value)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
+14
View File
@@ -2,6 +2,7 @@ using System.Diagnostics;
using System.IO;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
@@ -75,6 +76,19 @@ public partial class MainViewModel : ViewModelBase
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
private ZoomShape _zoomShape = ZoomShape.Circle;
public ZoomShape ZoomShape
{
get => _zoomShape;
set
{
if (SetProperty(ref _zoomShape, value))
{
try { _layoutStore.UpsertSetting("ZoomShape", value.ToString()); } catch { }
}
}
}
public string OverlayTitle
{
get => _overlayTitle;
@@ -334,6 +334,16 @@ public partial class MainViewModel : ViewModelBase
RecordPillOn = false;
OnAirPillOn = false;
WindowTitle = "LlamaCasty";
// Deactivate zoom when stopping
if (_zoomState.IsActive || _zoomState.IsMoving)
{
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
OnPropertyChanged(nameof(IsZoomActive));
OnPropertyChanged(nameof(ShowZoomOverlay));
OnPropertyChanged(nameof(ZoomState));
RebuildQualityDimRects();
}
ResetHealth(StreamStatus.Offline);
// Stop chat polling first (TASK 9.4) — no more messages arriving while
// we're tearing down the stream.
+1 -7
View File
@@ -411,13 +411,7 @@ public partial class MainViewModel : ViewModelBase
OutputRectWidth = rectW;
OutputRectHeight = rectH;
DimRects.Clear();
if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
if (rectX + rectW < MasterFrameWidth)
DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
if (rectY + rectH < MasterFrameHeight)
DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));
RebuildQualityDimRects();
IsOutputCropped = DimRects.Count > 0;
ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";
+161 -3
View File
@@ -28,6 +28,7 @@ public partial class MainViewModel : ViewModelBase
private string? _activeLayoutPath;
private DispatcherTimer? _saveDebounce;
private bool _isLoading;
private ZoomState _zoomState = new();
// Toasts (TASK 24): non-blocking user-facing messages. Parameterless ctor
// news up the real service; the VM is built from XAML so no DI seam exists.
@@ -74,6 +75,142 @@ public partial class MainViewModel : ViewModelBase
}
}
public ZoomState ZoomState
{
get => _zoomState;
set => SetProperty(ref _zoomState, value);
}
public bool IsZoomActive => _zoomState.IsActive;
public bool ShowZoomOverlay => _zoomState.IsActive;
public void ActivateZoom(double clickX, double clickY)
{
if (!(IsLive || IsRecording)) return;
var maxW = MasterFrameWidth * 0.7;
var maxH = MasterFrameHeight * 0.7;
double w, h;
switch (_zoomShape)
{
case ZoomShape.Circle:
var size = Math.Min(maxW, maxH);
w = size; h = size;
break;
case ZoomShape.VerticalRectangle:
h = maxH;
w = h * 9.0 / 16.0;
break;
default:
w = maxW;
h = w * 9.0 / 16.0;
break;
}
double x, y;
if (_zoomShape == ZoomShape.Circle)
{
x = clickX - w / 2;
y = clickY - h / 2;
}
else
{
x = clickX;
y = clickY;
}
x = Math.Clamp(x, 0, MasterFrameWidth - w);
y = Math.Clamp(y, 0, MasterFrameHeight - h);
_zoomState.X = x;
_zoomState.Y = y;
_zoomState.Width = w;
_zoomState.Height = h;
_zoomState.IsActive = true;
_zoomState.IsMoving = false;
RefreshZoomUi();
}
public void ToggleZoom()
{
if (_zoomState.IsActive)
{
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
RefreshZoomUi();
}
}
public void DismissZoom()
{
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
RefreshZoomUi();
}
public void StartZoomMove(double x, double y)
{
if (!_zoomState.IsActive) return;
_zoomState.IsMoving = true;
_zoomState.GrabOffsetX = x - _zoomState.X;
_zoomState.GrabOffsetY = y - _zoomState.Y;
OnPropertyChanged(nameof(ZoomState));
}
public void UpdateZoomMove(double x, double y)
{
if (!_zoomState.IsActive || !_zoomState.IsMoving) return;
var nx = x - _zoomState.GrabOffsetX;
var ny = y - _zoomState.GrabOffsetY;
_zoomState.X = Math.Clamp(nx, 0, MasterFrameWidth - _zoomState.Width);
_zoomState.Y = Math.Clamp(ny, 0, MasterFrameHeight - _zoomState.Height);
OnPropertyChanged(nameof(ZoomState));
RebuildDimRectsForZoom();
}
public void EndZoomMove()
{
if (_zoomState.IsMoving)
{
_zoomState.IsMoving = false;
OnPropertyChanged(nameof(ZoomState));
}
}
private void RefreshZoomUi()
{
OnPropertyChanged(nameof(ZoomState));
OnPropertyChanged(nameof(IsZoomActive));
OnPropertyChanged(nameof(ShowZoomOverlay));
RebuildDimRectsForZoom();
}
private void RebuildDimRectsForZoom()
{
if (_zoomState.IsActive)
{
var zx = _zoomState.X;
var zy = _zoomState.Y;
var zw = _zoomState.Width;
var zh = _zoomState.Height;
DimRects.Clear();
if (zx > 0) DimRects.Add(new Rect(0, 0, zx, MasterFrameHeight));
var rx = zx + zw;
if (rx < MasterFrameWidth) DimRects.Add(new Rect(rx, 0, MasterFrameWidth - rx, MasterFrameHeight));
if (zy > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, zy));
var ry = zy + zh;
if (ry < MasterFrameHeight) DimRects.Add(new Rect(0, ry, MasterFrameWidth, MasterFrameHeight - ry));
return;
}
RebuildQualityDimRects();
} private void RebuildQualityDimRects()
{
DimRects.Clear();
if (OutputRectWidth <= 0 || OutputRectHeight <= 0) return;
if (OutputRectX > 0) DimRects.Add(new Rect(0, 0, OutputRectX, MasterFrameHeight));
if (OutputRectX + OutputRectWidth < MasterFrameWidth)
DimRects.Add(new Rect(OutputRectX + OutputRectWidth, 0, MasterFrameWidth - OutputRectX - OutputRectWidth, MasterFrameHeight));
if (OutputRectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, OutputRectY));
if (OutputRectY + OutputRectHeight < MasterFrameHeight)
DimRects.Add(new Rect(0, OutputRectY + OutputRectHeight, MasterFrameWidth, MasterFrameHeight - OutputRectY - OutputRectHeight));
}
private void OnSelectedElementPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SceneElement.UseDefaultBackground))
@@ -239,6 +376,13 @@ public partial class MainViewModel : ViewModelBase
// (yellow) instead of silently falling back to the default.
MicSourceName = _layoutStore.LoadMicSourceName();
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
try
{
var s = _layoutStore.GetSetting("ZoomShape");
if (!string.IsNullOrWhiteSpace(s) && Enum.TryParse<ZoomShape>(s, true, out var zs))
_zoomShape = zs;
}
catch { }
_cameraEnumerator = CameraEnumeratorOverride ?? new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager(
@@ -553,6 +697,22 @@ public partial class MainViewModel : ViewModelBase
private CompositorOptions BuildCompositorOptions()
{
var quality = SelectedQuality;
if (_zoomState.IsActive)
{
var zx = _zoomState.X;
var zy = _zoomState.Y;
var zw = _zoomState.Width;
var zh = _zoomState.Height;
return new CompositorOptions
{
SourceRectX = (int)Math.Clamp(Math.Round(zx), 0, 1919),
SourceRectY = (int)Math.Clamp(Math.Round(zy), 0, 1079),
SourceRectWidth = (int)Math.Max(1, Math.Round(zw)),
SourceRectHeight = (int)Math.Max(1, Math.Round(zh)),
OutputWidth = quality.Width,
OutputHeight = quality.Height,
};
}
return new CompositorOptions
{
SourceRectX = (int)Math.Round(OutputRectX),
@@ -562,9 +722,7 @@ public partial class MainViewModel : ViewModelBase
OutputWidth = quality.Width,
OutputHeight = quality.Height,
};
}
// The credit's cadence, timing, placement and licence gate all live in
} // The credit's cadence, timing, placement and licence gate all live in
// BrandFlashPresenter; MainViewModel only routes it to the frame pump and the
// preview (see MainViewModel.BrandFlash.cs).
+106
View File
@@ -0,0 +1,106 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Shapes;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Verifies the zoom box created by a right-click in the live preview: each
/// ZoomView Shape procures the right bounding box (circle is square and
/// centered on the click, phone is a tall 9:16 rect, tablet a wide 16:9 rect
/// both anchored top-left at the click), the box stays fully inside the
/// 1920x1080 preview, and the UI renders the matching outline with the zoomed
/// region clipped to the shape.
/// </summary>
[Collection("RealApp")]
public sealed class ZoomBoxInteractionTests
{
private readonly RealAppHost _app;
public ZoomBoxInteractionTests(RealAppHost app)
{
_app = app;
}
[Fact]
public void Zoom_Shape_Controls_Box_Aspect_Anchor_And_Render()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
window.UpdateLayout();
// Same internal test seam as IsRecording allows ActivateZoom (IsEditMode goes false).
vm.IsRecording = true;
var pane = (UserControl)window.FindName("PreviewPane")!;
var zoomBox = (Grid)pane.FindName("ZoomBox")!;
var rectOutline = (Rectangle)pane.FindName("ZoomRectOutline")!;
var circleOutline = (Ellipse)pane.FindName("ZoomCircleOutline")!;
// Circle: square box, click point is the CENTER.
vm.ZoomShape = ZoomShape.Circle;
vm.ActivateZoom(1000, 500);
Assert.True(vm.IsZoomActive);
Assert.Equal(vm.ZoomState.Width, vm.ZoomState.Height, 3);
Assert.Equal(1000 - vm.ZoomState.Width / 2, vm.ZoomState.X, 3);
Assert.Equal(500 - vm.ZoomState.Height / 2, vm.ZoomState.Y, 3);
window.UpdateLayout();
Assert.Equal(Visibility.Visible, zoomBox.Visibility);
Assert.IsType<EllipseGeometry>(zoomBox.Clip);
Assert.Equal(Visibility.Visible, circleOutline.Visibility);
Assert.Equal(Visibility.Collapsed, rectOutline.Visibility);
// Phone (vertical rectangle): tall 9:16 box, click point is the TOP-LEFT corner.
vm.ZoomShape = ZoomShape.VerticalRectangle;
vm.ActivateZoom(200, 100);
Assert.True(vm.ZoomState.Height > vm.ZoomState.Width);
Assert.Equal(9.0 / 16.0, vm.ZoomState.Width / vm.ZoomState.Height, 3);
Assert.Equal(200, vm.ZoomState.X, 3);
Assert.Equal(100, vm.ZoomState.Y, 3);
window.UpdateLayout();
Assert.Null(zoomBox.Clip);
Assert.Equal(Visibility.Collapsed, circleOutline.Visibility);
Assert.Equal(Visibility.Visible, rectOutline.Visibility);
// Tablet (horizontal rectangle): wide 16:9 box, anchored top-left.
vm.ZoomShape = ZoomShape.HorizontalRectangle;
vm.ActivateZoom(200, 100);
Assert.True(vm.ZoomState.Width > vm.ZoomState.Height);
Assert.Equal(16.0 / 9.0, vm.ZoomState.Width / vm.ZoomState.Height, 3);
Assert.Equal(200, vm.ZoomState.X, 3);
Assert.Equal(100, vm.ZoomState.Y, 3);
// Clamp keeps the box fully inside the 1920x1080 preview even for a
// corner click that would otherwise overhang it.
vm.ActivateZoom(2000, 2000);
const double frameW = 1920, frameH = 1080;
Assert.True(vm.ZoomState.X >= 0 && vm.ZoomState.Y >= 0);
Assert.True(vm.ZoomState.X + vm.ZoomState.Width <= frameW);
Assert.True(vm.ZoomState.Y + vm.ZoomState.Height <= frameH);
}
finally
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { System.IO.File.Delete(tempDb); } catch { }
}
}
}