Compare commits
5
Commits
6b6284d05c
...
fe80e8c994
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe80e8c994 | ||
|
|
948dd08214 | ||
|
|
6bff21b234 | ||
|
|
4b3d82ff0f | ||
|
|
966e718f1e |
@@ -65,6 +65,11 @@
|
||||
<DataTrigger Binding="{Binding IsVisible}" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45"/>
|
||||
</DataTrigger>
|
||||
<!-- The transient zoom layer has no editing UI — never
|
||||
show it as a layer row (TASK 51). -->
|
||||
<DataTrigger Binding="{Binding IsZoomLayer}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Helpers:FocusPreservingListBox.ItemContainerStyle>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
+73
-32
@@ -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"
|
||||
@@ -54,13 +63,14 @@
|
||||
</ContextMenu>
|
||||
</Grid.ContextMenu>
|
||||
<!-- Live desktop/game background (bottom layer; the static
|
||||
image renders above it). -->
|
||||
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
|
||||
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"
|
||||
IsHitTestVisible="False"/>
|
||||
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
|
||||
IsHitTestVisible="False"/>
|
||||
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
|
||||
image renders above it). The zoom box samples the
|
||||
whole CanvasGrid, not just this layer (TASK 51). -->
|
||||
<Grid x:Name="BackdropLayer" IsHitTestVisible="False">
|
||||
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
|
||||
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
|
||||
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/>
|
||||
<Rectangle Stroke="#22c55e" StrokeThickness="6"/>
|
||||
</Grid>
|
||||
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
|
||||
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
|
||||
Width="1920" Height="1080">
|
||||
@@ -94,6 +104,12 @@
|
||||
<DataTrigger Binding="{Binding IsBackground}" Value="True">
|
||||
<Setter Property="IsHitTestVisible" Value="False"/>
|
||||
</DataTrigger>
|
||||
<!-- The zoom layer never renders here — the ZoomBox magnifier (and the
|
||||
output resolver) own its pixels (TASK 51). -->
|
||||
<DataTrigger Binding="{Binding IsZoomLayer}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Setter Property="IsHitTestVisible" Value="False"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Grid.Style>
|
||||
@@ -300,26 +316,6 @@
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="0,0,-13,-13"/>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding DimRects}" IsHitTestVisible="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Rectangle Width="{Binding Width}" Height="{Binding Height}"
|
||||
Fill="#000000" Opacity="0.55"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Rectangle IsHitTestVisible="False" Stroke="#e94560" StrokeThickness="3"
|
||||
Canvas.Left="{Binding OutputRectX}" Canvas.Top="{Binding OutputRectY}"
|
||||
Width="{Binding OutputRectWidth}" Height="{Binding OutputRectHeight}"
|
||||
@@ -388,11 +384,56 @@
|
||||
Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</Canvas>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</Canvas>
|
||||
</Grid>
|
||||
|
||||
<!-- The zoom dims: preview chrome ONLY — a SIBLING of CanvasGrid,
|
||||
never a descendant — so the zoom box's VisualBrush sample of
|
||||
CanvasGrid can never pick up the dim shading. The box must show
|
||||
the captured area clean at all times, even when dragged over the
|
||||
dims (creator ruling 2026-10-08). Renders above the composed
|
||||
content (dims the whole scene outside the box incl. the global
|
||||
overlays) and below the ZoomBox. -->
|
||||
<ItemsControl x:Name="DimRectsLayer" ItemsSource="{Binding DimRects}" IsHitTestVisible="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<Canvas/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
<Setter Property="Canvas.Left" Value="{Binding X}"/>
|
||||
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
|
||||
</Style>
|
||||
</ItemsControl.ItemContainerStyle>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Rectangle Width="{Binding Width}" Height="{Binding Height}"
|
||||
Fill="#000000" Opacity="0.55"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- The zoom box: layer n+1 — a SIBLING of CanvasGrid (not a
|
||||
descendant), so its VisualBrush can sample the whole
|
||||
composed preview (CanvasGrid = backdrop + every element
|
||||
layer) without a circular reference, and it renders ABOVE
|
||||
everything. It shows a fixed capture window (box/2 at the
|
||||
click); dragging the box relocates it, never the capture. -->
|
||||
<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" Stretch="Fill"/>
|
||||
</Grid.Background>
|
||||
</Grid>
|
||||
</Canvas>
|
||||
</Viewbox>
|
||||
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
|
||||
Background="#CCe94560" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"
|
||||
|
||||
@@ -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,40 @@ public partial class PreviewPane : UserControl
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateZoomBox()
|
||||
{
|
||||
if (DataContext is not MainViewModel vm) return;
|
||||
if (vm.IsZoomActive && ZoomVisualBrush != null)
|
||||
{
|
||||
// The preview mirrors the output zoom layer: sample the WHOLE composed
|
||||
// preview (CanvasGrid = backdrop + every element layer — the same all-layer
|
||||
// source the output compositor magnifies) at the FIXED capture window (the
|
||||
// box/2 region centred on the click) → 2x via Stretch=Fill. Moving the box
|
||||
// only moves the ZoomBox element; the Viewbox stays glued to the capture.
|
||||
ZoomVisualBrush.Visual = CanvasGrid;
|
||||
ZoomVisualBrush.Viewbox = new Rect(
|
||||
vm.ZoomState.CaptureX,
|
||||
vm.ZoomState.CaptureY,
|
||||
vm.ZoomState.Width / 2,
|
||||
vm.ZoomState.Height / 2);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-542
@@ -1,542 +1,60 @@
|
||||
# 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-08 (zoom box shipped, READY FOR PUSH)
|
||||
|
||||
## Branch
|
||||
- main (no feature branch)
|
||||
|
||||
## Push readiness
|
||||
- **5 commits ahead of origin/main, clean to push** (fetch confirmed origin has not moved):
|
||||
`966e718` zoom box visuals → `4b3d82f` zoom as layered + TASK 51 booking → `6bff21b`
|
||||
anchored-capture rework → `948dd08` dims-shading fix → (docs unit: TASK 52 record +
|
||||
numbering fixes + this HANDOFF).
|
||||
- The pinned-chat work unit below is **uncommitted on purpose** — `git push` sends only
|
||||
commits, so pushing is safe; the dirty files stay local.
|
||||
|
||||
## Dirty files (NOT mine — the in-flight "pinned chat" work unit; do NOT commit or co-edit)
|
||||
- `Models/YouTube.cs`
|
||||
- `ViewModels/MainViewModel.Chat.cs`
|
||||
- `Controls/LeftPanel.xaml` (pinned-message UI)
|
||||
- Untracked: `Helpers/RelayCommandGeneric.cs`
|
||||
|
||||
## What just happened (2026-10-08, third zoom pass — dims shading)
|
||||
The zoom box shipped as a **loupe** (`4b3d82f`), then was corrected to an **anchored-capture
|
||||
magnifier over the full composite drawn last** (commit `6bff21b`). The final correction: as the box
|
||||
is dragged, the dim rectangles (55% black "div shading" around the box) were sliding under the
|
||||
capture region — shown inside the box because its `VisualBrush` samples all of `CanvasGrid`, and the
|
||||
`DimRects` ItemsControl previously lived INSIDE `CanvasGrid`.
|
||||
|
||||
Fix: the `DimRects` overlay is now a **sibling of `CanvasGrid`** (renders above the composed
|
||||
content, below the `ZoomBox`), never a descendant — so the zoom sample can never pick up the shading
|
||||
and the captured area stays clear no matter where the box is dragged. Behavior consequence to note:
|
||||
in non-zoom ("quality dims") mode the dims now also shade the global overlays (brand flash, alert
|
||||
ticker, social bar) that sit above them in the old tree — consistent with "chrome outside the crop
|
||||
is dimmed". Structural invariant added to `ZoomBoxInteractionTests`: `DimRectsLayer` is a direct
|
||||
child of `PreviewCanvas`, not inside `CanvasGrid`, and renders below the `ZoomBox`.
|
||||
|
||||
## Zoom feature state
|
||||
- **TASK 52 (zoom box) is now a first-class record** — see [`TASKS/task-52-zoom-box.md`](TASKS/task-52-zoom-box.md):
|
||||
feature provenance + the three creator corrections, shapes, implementation map, tests.
|
||||
TASK 51 (webcam background Remove + Blur) is unblocked for the next unit.
|
||||
- Models: `ZoomState.cs`, `ZoomShape.cs` (Circle/VerticalRectangle/HorizontalRectangle), `ZoomSceneElement.cs`.
|
||||
- ViewModel (`MainViewModel.Zoom.cs`): `ActivateZoom`/`ToggleZoom`/`DismissZoom`/`StartZoomMove`/`UpdateZoomMove`/`EndZoomMove` + `RebuildDimRectsForZoom`; transient element healed/mirrored by `HealZoomLayers`/`EnsureZoomLayer`.
|
||||
- Output: box composited last via `BlitZoom`/`ZoomMagnifier`; output crop = tier (box never the crop).
|
||||
- Preview: `ZoomBox` + `DimRectsLayer` are both SIBLINGS of `CanvasGrid` (box above dims above content); box samples the full composite at the fixed capture, 2x; circle→ellipse clip, rect/square outline per shape; `LeftPanel` hides the placeholder row via `IsZoomLayer`.
|
||||
- Rectangle shape = 4:3; Circle square; Phone 9:16. Capture anchored on click (`CaptureX/Y`); dragging the box never changes it.
|
||||
|
||||
## Tests
|
||||
- Full suite: **406 passed / 0 failed** on a clean full run (the pre-existing audio-loopback timing
|
||||
flake and `RealMouseDrag`'s interactive-desktop requirement recur occasionally — not zoom
|
||||
regressions). Zoom tests 3/3 green, 0 build warnings.
|
||||
|
||||
## Known issues / follow-ups
|
||||
- **Background "Use default background" toggle still not working** (carried from 2026-10-07):
|
||||
binding exists (`UseDefaultBackground`), VM handler invalidates bake; investigate when time permits.
|
||||
- The 3px selection / crop-outline strokes could still show in the zoom box IF the capture overlaps
|
||||
them in edit mode — not shading, thin outlines only; the dims (the user's complaint) are gone.
|
||||
|
||||
## Next step
|
||||
- User runs the app live to eyeball: drag the zoom box anywhere and the captured content stays clean
|
||||
(no dim shading in the box; the scene outside the box stays dimmed). Then TASK 51 (webcam
|
||||
background Remove + Blur) per `TASKS/task-51-webcam-bg-effects.md`, and the background-toggle
|
||||
investigation if still open.
|
||||
@@ -60,6 +60,10 @@ public abstract class SceneElement : INotifyPropertyChanged
|
||||
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
|
||||
public virtual ElementKind Kind => ElementKind.Static;
|
||||
|
||||
/// <summary>True only for the transient zoom layer. Gates zoom-only UI:
|
||||
/// hidden from the layers list, never selectable/draggable in the preview.</summary>
|
||||
public virtual bool IsZoomLayer => false;
|
||||
|
||||
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
|
||||
public virtual bool IsWebcam => false;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The transient zoom layer. It is a real scene element, but a special one: while
|
||||
/// active the SceneCompositor draws it LAST (layer n+1, above everything) as a live
|
||||
/// 2x view of a FIXED capture window (the box/2 region centred on where the creator
|
||||
/// clicked), sampled from the just-composited frame — so it captures ALL layers and
|
||||
/// keeps showing the same content no matter where the box is dragged (creator ruling
|
||||
/// 2026-10-08). Its X/Y/W/H are the movable BOX placement; CaptureX/CaptureY is the
|
||||
/// anchored capture window. While inactive it is an invisible placeholder that is
|
||||
/// never persisted (LayoutStore hand-rolls only Source/WebcamSceneConfig rows) — the
|
||||
/// VM re-inserts it on load and on scene creation, mirroring how backgrounds heal.
|
||||
/// </summary>
|
||||
public sealed class ZoomSceneElement : SceneElement
|
||||
{
|
||||
/// <summary>True only while a zoom box is live; flips Kind so the layer leaves
|
||||
/// the baked static base and composites per-frame.</summary>
|
||||
public bool IsZoomActive { get; set; }
|
||||
|
||||
/// <summary>Master-space top-left of the fixed capture window. Set once by
|
||||
/// <c>ActivateZoom</c>; moving the box must never touch it.</summary>
|
||||
public double CaptureX { get; set; }
|
||||
public double CaptureY { get; set; }
|
||||
|
||||
public override ElementKind Kind => IsZoomActive ? ElementKind.Dynamic : ElementKind.Static;
|
||||
|
||||
public override bool IsZoomLayer => true;
|
||||
|
||||
public override bool IsDraggable => false;
|
||||
|
||||
/// <summary>Never rendered by the element template or the resolver — the preview
|
||||
/// magnifier (ZoomBox) and the output compositor's zoom pass own its pixels.</summary>
|
||||
public override ImageSource? DisplaySource => null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
public enum ZoomShape
|
||||
{
|
||||
Circle,
|
||||
VerticalRectangle,
|
||||
HorizontalRectangle
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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; }
|
||||
|
||||
/// <summary>Master-space top-left of the FIXED capture window (the box/2 region
|
||||
/// centred on the click). The box can be dragged anywhere; this never changes —
|
||||
/// the box always shows this same region magnified 2x (creator ruling 2026-10-08).</summary>
|
||||
public double CaptureX { get; set; }
|
||||
public double CaptureY { 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;
|
||||
}
|
||||
+2
-1
@@ -8,7 +8,8 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
|
||||
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
|
||||
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
|
||||
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||
|
||||
@@ -1475,3 +1475,73 @@ 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.
|
||||
|
||||
## 2026-10-08 — Editing a file that carries ANOTHER work unit's uncommitted changes
|
||||
|
||||
LeftPanel.xaml was dirty with unrelated "pinned chat" work; the zoom build needed one DataTrigger
|
||||
inserted into its ItemContainerStyle. Committing `git add Controls/LeftPanel.xaml` would have swept
|
||||
the OTHER work unit's hunks into my commit — a scope-lock violation. The clean recipe:
|
||||
|
||||
```
|
||||
git stash push -m my-wunit-isolate -- Controls/LeftPanel.xaml # park the OTHER work unit
|
||||
# edit + build + test + commit my hunk (file is now only-mine vs HEAD)
|
||||
git stash pop # restore the other work unit
|
||||
```
|
||||
|
||||
Rules that make it safe: stash the WHOLE file (a per-hunk stash is a conflict factory), verify the
|
||||
playback diff only touches regions I did not edit (`git show 'stash@{0}' -- <file>` before popping),
|
||||
and check the other unit is preserved after the pop. Non-overlapping hunks pop cleanly; if a future
|
||||
stash pop conflicts, resolve with the OTHER work unit winning.
|
||||
|
||||
## 2026-10-08 — The zoom window formula: source = box/2 centered on the BOX CENTER
|
||||
|
||||
First cut of `ZoomFrameBuilder` sampled the box's LEFT HALF (`mx = zx + (ox+.5)/2`), a silent
|
||||
half-frame mis-focus — the preview's VisualBrush Viewbox is `(X+W/4, Y+H/4, W/2, H/2)`, i.e. the
|
||||
half-width window centered on the box center. The output sampler must mirror it exactly:
|
||||
|
||||
```
|
||||
mx = zx + W/4 + (ox + 0.5)/2 // ox over [0,W)
|
||||
```
|
||||
|
||||
A one-constant difference (`W/4` vs nothing) is invisible in a real scene but fatal to a banded
|
||||
gradient test — which is exactly why the magnification proof exists: 4 vertical bands, a box whose
|
||||
1x region straddles two of them, assert ALL output pixels are the center-band color. If you change
|
||||
the preview window, change `ZoomFrameBuilder` in the same edit or the preview lies about the output.
|
||||
|
||||
## 2026-10-08 — The zoom box is NOT a loupe: anchored capture, all-layer content, drawn LAST
|
||||
|
||||
I shipped the whole first zoom layer (commit `4b3d82f`) with a "loupe" mental model — the box shows
|
||||
what's *under it* in the output — and the creator corrected it in one message: the click fixes a
|
||||
**capture region** and the box is a live, always-on 2x view of THAT region; moving the box changes
|
||||
NOTHING about its content; it magnifies the fully-composited frame (every layer, including webcam),
|
||||
and it must draw **on top of everything** (layer n+1), not slot at z-index 1. Three wrongs, all made
|
||||
"elegantly": a BoxFollowing frame builder, a Backdrop+first-layer-only sample, and a z=1 layering.
|
||||
|
||||
The corrected test that would have caught all three in one shot (`ZoomMagnifierTests`): a banded
|
||||
buffer (blue left / green right) with the box's left quarter OVER a blue region while the capture
|
||||
sits wholly in green — assert **no box pixel is blue**. A loupe-style sampler fails it, a backdrop-
|
||||
only sampler fails the "red webcam square appears magnified" assert, and `SceneCompositor` skips the
|
||||
active zoom in every layer loop so nothing else can cover it. Lessons: (1) ask the *triggering
|
||||
semantics* — anchored-capture vs follow-the-box — before building any "zoom" UI; (2) prove content
|
||||
captures ALL layers with an extra layer in the fixture and assert it appears; (3) "draw above
|
||||
everything" is a compositor ordering property (skip in the layer loops + blit last), not a z-index
|
||||
of the element among the others.
|
||||
|
||||
@@ -157,6 +157,7 @@ public sealed class SceneCompositor
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
var fullFrame = frameFor(element);
|
||||
@@ -175,6 +176,7 @@ public sealed class SceneCompositor
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
@@ -191,6 +193,14 @@ public sealed class SceneCompositor
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
|
||||
// The zoom box is layer n+1: a live 2x view of a FIXED capture window sampled
|
||||
// from the frame just composited (all layers). Drawn LAST — above everything.
|
||||
// The box/2 capture is anchored at the click, so dragging the box only moves
|
||||
// the box; the content it shows never changes (creator ruling 2026-10-08).
|
||||
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
|
||||
if (zoom != null)
|
||||
BlitZoom(buffer, cropW, cropH, options, zoom);
|
||||
|
||||
return StretchMath.BilinearScale(
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
@@ -249,6 +259,7 @@ public sealed class SceneCompositor
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
@@ -299,6 +310,7 @@ public sealed class SceneCompositor
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
@@ -309,6 +321,10 @@ public sealed class SceneCompositor
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
|
||||
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
|
||||
if (zoom != null)
|
||||
BlitZoom(buffer, cropW, cropH, options, zoom);
|
||||
|
||||
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
@@ -834,6 +850,23 @@ public sealed class SceneCompositor
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The zoom box layer (n+1): map the element's master-space box and capture to
|
||||
/// crop space and 2x-magnify the capture onto the box from the composed frame.
|
||||
/// The active zoom element is never blitted by the normal layer loops — the
|
||||
/// switch skips it and this pass draws it above everything, last.
|
||||
/// </summary>
|
||||
private static void BlitZoom(
|
||||
byte[] dst, int cropW, int cropH, CompositorOptions options, ZoomSceneElement zoom)
|
||||
{
|
||||
ZoomMagnifier.MagnifyInto(
|
||||
dst, cropW, cropH,
|
||||
zoom.X - options.SourceRectX, zoom.Y - options.SourceRectY,
|
||||
zoom.Width, zoom.Height,
|
||||
zoom.CaptureX - options.SourceRectX, zoom.CaptureY - options.SourceRectY,
|
||||
zoom.ClipShape == ClipShape.Round);
|
||||
}
|
||||
|
||||
/// <summary>Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.</summary>
|
||||
private static void BlendPixel(byte[] dst, int di, (byte B, byte G, byte R, byte A) src, float opacity)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
|
||||
namespace ytLive.Services.Compositor;
|
||||
|
||||
/// <summary>
|
||||
/// The zoom box's magnifier (creator-corrected model, 2026-10-08): the box is a
|
||||
/// detached 2x view of a FIXED capture window — the box/2 region centred on the
|
||||
/// spot the creator clicked — sampled from the ALREADY-COMPOSITED frame (every
|
||||
/// layer: live backdrop, art, webcam, sources, alerts). Moving the box relocates
|
||||
/// it on screen without changing the capture; the box is painted LAST (layer n+1)
|
||||
/// so it sits above everything. The capture snapshot is taken before the box is
|
||||
/// drawn, so a box overlapping its own source never recurses.
|
||||
/// </summary>
|
||||
internal static class ZoomMagnifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Magnify a box/2 capture window onto a box/2 ² box in <paramref name="dst"/>
|
||||
/// (an opaque, already-composited crop-space buffer). dst pixel centres map to
|
||||
/// capture coords as <c>cap + (dstLocal + 0.5) / 2</c> — the exact 2x the preview's
|
||||
/// VisualBrush shows via a box/2 Viewbox with Stretch=Fill. <paramref name="round"/>
|
||||
/// clips the box to a true circle (zoom shape Circle).
|
||||
/// </summary>
|
||||
internal static void MagnifyInto(
|
||||
byte[] dst, int dstW, int dstH,
|
||||
double boxX, double boxY, double boxW, double boxH,
|
||||
double capX, double capY,
|
||||
bool round)
|
||||
{
|
||||
if (boxW <= 0 || boxH <= 0) return;
|
||||
if (dstW <= 0 || dstH <= 0) return;
|
||||
|
||||
// Integer box draw bounds (clamped to the buffer).
|
||||
var bx0 = Math.Max(0, (int)Math.Floor(boxX));
|
||||
var by0 = Math.Max(0, (int)Math.Floor(boxY));
|
||||
var bx1 = Math.Min(dstW, (int)Math.Ceiling(boxX + boxW));
|
||||
var by1 = Math.Min(dstH, (int)Math.Ceiling(boxY + boxH));
|
||||
if (bx0 >= bx1 || by0 >= by1) return;
|
||||
var bw = bx1 - bx0;
|
||||
var bh = by1 - by0;
|
||||
|
||||
// Integer capture-window read bounds (the 2x window is box/2, clamped).
|
||||
var rx0 = Math.Max(0, (int)Math.Floor(capX));
|
||||
var ry0 = Math.Max(0, (int)Math.Floor(capY));
|
||||
var rx1 = Math.Min(dstW, (int)Math.Ceiling(capX + boxW / 2.0));
|
||||
var ry1 = Math.Min(dstH, (int)Math.Ceiling(capY + boxH / 2.0));
|
||||
var rw = rx1 - rx0;
|
||||
var rh = ry1 - ry0;
|
||||
if (rw <= 0 || rh <= 0) return;
|
||||
|
||||
// Snapshot the capture first: the box may overlap its own source region.
|
||||
var src = ArrayPool<byte>.Shared.Rent(rw * rh * 4);
|
||||
try
|
||||
{
|
||||
for (var y = 0; y < rh; y++)
|
||||
Buffer.BlockCopy(dst, ((ry0 + y) * dstW + rx0) * 4, src, y * rw * 4, rw * 4);
|
||||
|
||||
var radius = Math.Min(boxW, boxH) / 2.0;
|
||||
var ccx = boxX + boxW / 2.0;
|
||||
var ccy = boxY + boxH / 2.0;
|
||||
|
||||
for (var y = 0; y < bh; y++)
|
||||
{
|
||||
var dy = by0 + y;
|
||||
var sy = capY + (dy + 0.5 - boxY) * 0.5 - ry0;
|
||||
if (sy < 0.0) sy = 0.0;
|
||||
if (sy > rh - 1.0) sy = rh - 1.0;
|
||||
var syi = (int)sy;
|
||||
var syInv = Math.Min(syi + 1, rh - 1);
|
||||
var fy8 = (int)((sy - syi) * 256);
|
||||
var fyInv = 256 - fy8;
|
||||
|
||||
var di = (dy * dstW + bx0) * 4;
|
||||
for (var x = 0; x < bw; x++, di += 4)
|
||||
{
|
||||
var dx = bx0 + x;
|
||||
if (round)
|
||||
{
|
||||
var ox = dx - ccx;
|
||||
var oy = dy - ccy;
|
||||
if (ox * ox + oy * oy > radius * radius) continue;
|
||||
}
|
||||
var sx = capX + (dx + 0.5 - boxX) * 0.5 - rx0;
|
||||
if (sx < 0.0) sx = 0.0;
|
||||
if (sx > rw - 1.0) sx = rw - 1.0;
|
||||
var sxi = (int)sx;
|
||||
var sxInv = Math.Min(sxi + 1, rw - 1);
|
||||
var fx8 = (int)((sx - sxi) * 256);
|
||||
var fxInv = 256 - fx8;
|
||||
|
||||
var p00 = (syi * rw + sxi) * 4;
|
||||
var p10 = (syi * rw + sxInv) * 4;
|
||||
var p01 = (syInv * rw + sxi) * 4;
|
||||
var p11 = (syInv * rw + sxInv) * 4;
|
||||
dst[di] = (byte)Bilinear(src[p00], src[p10], src[p01], src[p11], fx8, fxInv, fy8, fyInv);
|
||||
dst[di + 1] = (byte)Bilinear(src[p00 + 1], src[p10 + 1], src[p01 + 1], src[p11 + 1], fx8, fxInv, fy8, fyInv);
|
||||
dst[di + 2] = (byte)Bilinear(src[p00 + 2], src[p10 + 2], src[p01 + 2], src[p11 + 2], fx8, fxInv, fy8, fyInv);
|
||||
dst[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(src);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Two-stage bilinear in 8.8 fixed point (same precision as
|
||||
/// <see cref="SceneCompositor"/>'s sampler).</summary>
|
||||
private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv)
|
||||
{
|
||||
var top = tl * fxInv + tr * fx8;
|
||||
var bot = bl * fxInv + br * fx8;
|
||||
return (top * fyInv + bot * fy8 + 32768) >> 16;
|
||||
}
|
||||
}
|
||||
@@ -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;";
|
||||
|
||||
@@ -66,6 +66,8 @@
|
||||
| 48 | Distribution & packaging: **MSIX + Store IAP** | 🔶 In progress — **✅ route decided 2026-09-27 (Store MSIX + Store IAP)**; **item 1 (bundle ffmpeg) SHIPPED 2026-09-28** | [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md) |
|
||||
| 49 | Chat profanity filter (local, opt-in, non-persistent, user word list) | ☐ Queued (2026-09-27) | [`TASKS/task-49-chat-profanity-filter.md`](TASKS/task-49-chat-profanity-filter.md) |
|
||||
| 50 | One-button "Publish this video" (resumable upload of the local recording) | ☐ Queued (2026-09-28) | [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md) |
|
||||
| 51 | **Webcam background effects: Remove + Blur** | ☐ **v1 requirement** — booked 2026-10-08; next up now that the zoom box shipped (TASK 52) | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) |
|
||||
| 52 | **Zoom box (anchored 2× magnifier)** | ✅ Done — shipped 2026-10-08 (3 creator corrections during the build: loupe → anchored capture over the full composite, drawn last → never shows the dim shading) | [`TASKS/task-52-zoom-box.md`](TASKS/task-52-zoom-box.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -177,7 +179,9 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
|
||||
exclusive camera lock.** API probe already done — see HANDOFF.md, incl. the `SetFormatAsync`
|
||||
dead end.
|
||||
- ⛔ **No fallback-camera feature, ever** — *"that's bypassing the user's explicit choice."*
|
||||
- ⛔ **No background removal** — re-affirmed out 2026-10-05.
|
||||
- **Background removal + blur = v1 requirement (2026-10-08)** — reverses the 2026-10-05
|
||||
exclusion. Bundled ONNX + onnxruntime, no downloads, no GPU requirement. Per-element
|
||||
Remove/Blur + strength. See [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md).
|
||||
- **TASK 40 — App Settings round** — plan saved 2026-09-22 (`TASKS/task-40-app-settings-round.md`),
|
||||
four units, one Good Dog test each: **A** active web camera section under Recordings (Change…
|
||||
sub-menu = live enumeration incl. vcams; Tune… config dialog via `ICameraControlProbe`, live-only
|
||||
@@ -379,9 +383,12 @@ See [`TASKS/research-youtube-api.md`](TASKS/research-youtube-api.md) — authori
|
||||
These were explicitly ruled out:
|
||||
|
||||
- **D3D11 swap compositor** (superseded by software compositor; TASK 3 item 16)
|
||||
- **Background removal** (ONNX/DirectML; TASK 3 item 19) — ⛔ **re-affirmed out 2026-10-05**: *"no - not
|
||||
doing it. the note is correct and accurate."* A vcam app does it free; an in-app model is a runtime,
|
||||
a GPU/driver matrix, and per-scene lighting tuning. **Never reopen without a new ruling.**
|
||||
- **Background removal + blur** (ONNX/DirectML; TASK 3 item 19) — ⛔ ruled out 2026-10-05
|
||||
(*"no - not doing it... a vcam app does it free; an in-app model is a runtime, a GPU/driver
|
||||
matrix, per-scene lighting tuning"*). **REVERSED 2026-10-08 — now a v1 requirement**
|
||||
([`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md)): bundled
|
||||
~2–5 MB ONNX model + onnxruntime, CPU-capable, no downloads, no NVIDIA. NVIDIA VFX SDK
|
||||
(~1 GB per-client redistributable, RTX 2000+) remains ruled out.
|
||||
- **OBS-style source expansion** (game capture, browser source, media playlist, VLC, color-key, MIDI — the minimal source set is deliberate; creators who need more have graduated to OBS)
|
||||
- **Multi-layout** (auto-save, single layout; multi-layout = OBS territory)
|
||||
- **Cross-process stream resume** (API makes it impossible; creator ruling 2026-09-01)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# TASK 51 — Webcam background effects: Remove + Blur (v1 requirement)
|
||||
|
||||
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
|
||||
> **Booked 2026-10-08 by creator ruling** — *"we're doing the bg removal and blur for v1 - book it, Danno"*.
|
||||
> This **reverses** the 2026-10-05 "background removal: out of product" exclusion (TASK 3 item 19).
|
||||
|
||||
**Status:** ☐ Booked — **v1 requirement**. Queued behind the zoom build (current in-flight work unit).
|
||||
|
||||
**Goal:** per-webcam **Background Remove** and **Background Blur** on the live webcam layer,
|
||||
selected per element in its properties panel, composited into stream + recording like any
|
||||
other layer. No external downloads, no hardware requirement, works on any machine.
|
||||
Replacement backgrounds are **free via layers** — drop any element behind the webcam.
|
||||
|
||||
---
|
||||
|
||||
## Why this is now IN (and what changed)
|
||||
|
||||
The 2026-10-05 exclusion was based on: an in-app AI model = a runtime, a GPU/driver matrix,
|
||||
per-scene lighting tuning, and "a vcam app does it free." The ruling is reversed because the
|
||||
delivery shape changed:
|
||||
|
||||
1. **Bundled small ONNX model (~2–5 MB)** — MediaPipe Selfie-Segmentation family —
|
||||
shipped in the installer/fixture (TASK 48 MSIX), executed by **onnxruntime** (CPU default;
|
||||
DirectML optional later). No NVIDIA SDK, no GPU requirement, no per-client download.
|
||||
2. **Quality reality (creator):** a 720p webcam displays at ~15% of a 1080p frame — premium
|
||||
segmentation quality is invisible at that size; commodity class is sufficient.
|
||||
3. **⛔ Ruled out: NVIDIA Video Effects SDK (VFX / Maxine, AI Green Screen + Background Blur).**
|
||||
Works on RTX 2000+/R570+ only, and needs a ~1 GB user-side redistributable on first use.
|
||||
Creator: *"I don't think I'm putting a 1gb download requirement on a client box."*
|
||||
|
||||
## Constraints — non-negotiable
|
||||
|
||||
1. **No external download at runtime.** Model ships in the installer. (Store policy 10.2.2 —
|
||||
same posture as the ffmpeg bundling in TASK 48 item 1.)
|
||||
2. **No hardware requirement.** CPU path must work; DirectML is an optional accelerator.
|
||||
3. **Per-element (Meld-style), not global.** Each webcam element carries its own effect.
|
||||
4. **No feature gating.** Works for free users; only the branding flash is paid.
|
||||
5. **Scene change / webcam add/remove must not crash or stall the pump** — a failing model
|
||||
run skips to passthrough, never drops the layer.
|
||||
|
||||
## Out of scope for v1 (same engine later)
|
||||
|
||||
- ❌ **"Remove chair"-style object selection / quality tiers** — not a picker engine
|
||||
- ❌ **Super resolution / denoise / prettify passes**
|
||||
- ❌ **Virtual camera output** — permanently out of product (no vcam)
|
||||
|
||||
## 1. ☐ Engine
|
||||
|
||||
- ☐ Bundle an ONNX person-segmentation model (MediaPipe Selfie-Segmentation class, ~2–5 MB)
|
||||
into the app (csproj copy like `ffmpeg/`, or as an `Asset`). Verify license + THIRD-PARTY
|
||||
notices (TASK 36 item 6 gate).
|
||||
- ☐ `onnxruntime` via NuGet. CPU EP by default; optionally DirectML EP later. Failing EP
|
||||
init → passthrough, log once, never throw from the pump.
|
||||
- ☐ Blur = **separable in-app blur** (box/gaussian) over the masked background region. No
|
||||
model needed for blur once the mask exists.
|
||||
- ☐ Remove = **alpha 0 outside the person region.** The compositor already alpha-blends per
|
||||
element (`SceneCompositor`, source-over) — no compositor change expected.
|
||||
- ☐ Feed the **VideoFrame seam** (`Services/VideoFrame.cs` doc explicitly anticipates
|
||||
*"every future capture source (screen, background-removed webcam) feeds the same seam"*):
|
||||
the frame handed to BOTH the preview bitmap path and the compositor's
|
||||
`_cameraManager.GetLatestFrame` is the processed frame. One processing point, two consumers.
|
||||
|
||||
## 2. ☐ Config surface + persistence
|
||||
|
||||
- ☐ `WebcamSceneConfig` gains `BackgroundEffect` (`None | Remove | Blur`) + `Strength`.
|
||||
- ☐ Persist via the existing `WebcamSceneConfig` table path in `LayoutStore.Save.cs`/
|
||||
`Load.cs` (new columns + migration). Follow `LayoutStore.Migrations.cs` conventions.
|
||||
- ☐ Properties panel: effect dropdown + strength slider, per webcam element.
|
||||
- ☐ Replace-background affordance: **none in v1** — the creator drops any layer behind the
|
||||
webcam. Effect preview must not obscure the fact that layers below still show through.
|
||||
|
||||
## 3. ☐ Test seam (Good Dog — one integration test per unit)
|
||||
|
||||
- ☐ Model-runner interface seam (like `CameraEnumeratorOverride`) so tests inject a fake
|
||||
segmenter — no real ONNX inference in the test suite.
|
||||
- ☐ Assert the **processed frame reaches both consumers** (preview bitmap + compositor frame).
|
||||
- ☐ `None` = byte-identical passthrough of the raw frame.
|
||||
- ☐ `Remove` = alpha 0 outside person rect; `Blur` = background region blurred, person sharp.
|
||||
- ☐ Config → persistence roundtrip (per webcam element).
|
||||
- ☐ Engine failure → passthrough + single log, layer never dropped.
|
||||
- ☐ Real inference: **manual on the creator's box** (camera + lighting), not in CI.
|
||||
|
||||
## 4. ☐ Sequencing
|
||||
|
||||
- ☐ **Separate work unit AFTER the zoom build.** This file is booked + scoped; do not
|
||||
co-commit it with the zoom work.
|
||||
|
||||
## Related (not duplicated here)
|
||||
|
||||
- [`task-48-distribution-msix.md`](task-48-distribution-msix.md) — the model ships in the
|
||||
MSIX; bundled-binary posture established by the ffmpeg work.
|
||||
- `TASKS.md` Webcam cadence section — the "⛔ no background removal" line is superseded
|
||||
by this ruling; the **judder/compositor cost work is unaffected and still heads its lane.**
|
||||
- `research-store-certification.md` — 11.12/10.2.2 posture; nothing here sends data off-box.
|
||||
@@ -0,0 +1,48 @@
|
||||
# TASK 52 — Zoom box (anchored 2× magnifier)
|
||||
|
||||
**Status:** ✅ Done — shipped 2026-10-08 (three creator corrections during the build).
|
||||
|
||||
## What it is
|
||||
Right-clicking the live preview parks a movable **2× magnifier box** that shows a
|
||||
**live, always-on magnified view of a FIXED capture region** — the box/2 window centred
|
||||
on the click. The captured area is whatever was under the click; it stays the same no
|
||||
matter where the box is dragged.
|
||||
|
||||
## Creator rulings (order received, 2026-10-08)
|
||||
1. **Not a loupe.** The click fixes the capture; **moving the box never changes its
|
||||
content**. First cut shipped `4b3d82f` as a box-following loupe — reworked in
|
||||
`6bff21b` to anchored `CaptureX/Y` (drag = `UpdateZoomMove` moves box only).
|
||||
2. **Content = every layer.** The box magnifies the **fully-composited frame**
|
||||
(backdrop + every element incl. webcam), drawn **LAST, on top of everything**
|
||||
(layer n+1) — `SceneCompositor` skips the active `ZoomSceneElement` in every layer
|
||||
loop and `BlitZoom`s it at the end via `Services/Compositor/ZoomMagnifier`.
|
||||
3. **Never show shading.** The dim rectangles around the box must never appear inside
|
||||
it — the `DimRects` overlay is a sibling of `CanvasGrid`, never a descendant, so the
|
||||
zoom box's `VisualBrush` can't pick it up (`948dd08`).
|
||||
4. **Rectangle = 4:3** ("more of a 640×480 aspect ratio"); Circle square, Phone 9:16.
|
||||
|
||||
## Shapes (master 1920×1080; max box 0.7·frame)
|
||||
| Shape | Box | Notes |
|
||||
|---|---|---|
|
||||
| Circle | square, min(maxW,maxH) | round clip |
|
||||
| VerticalRectangle (Phone) | 9:16 | h = 0.7·1080 |
|
||||
| HorizontalRectangle (Tablet, default) | **4:3** | w = h·4/3 |
|
||||
|
||||
Box and capture are each centred on the click and clamped independently.
|
||||
|
||||
## Implementation
|
||||
- `ViewModels/MainViewModel.Zoom.cs` — activate/move/dismiss + dims; transient
|
||||
`ZoomSceneElement` healed/mirrored (`HealZoomLayers`/`EnsureZoomLayer`, z-index 1, never persisted).
|
||||
- `Services/Compositor/ZoomMagnifier.cs` — pure 2× bilinear over an already-composited
|
||||
opaque buffer (temp snapshot via `ArrayPool`, optional round clip).
|
||||
- `Models/ZoomSceneElement.cs` / `Models/ZoomState.cs` — box rect + `CaptureX/Y`.
|
||||
- `Controls/PreviewPane.xaml(.cs)` — `ZoomBox` (sibling above `CanvasGrid`, brushes the
|
||||
whole composed preview at the fixed capture, 2×) + `DimRectsLayer` (sibling above the
|
||||
content, below the box) + `UpdateZoomBox`.
|
||||
- Output crop always equals the tier — the box is never the crop source.
|
||||
|
||||
## Tests
|
||||
`ZoomMagnifierTests` (money proof: box over blue shows green capture at 2× incl. an extra
|
||||
red "layer"; round-clip) + `ZoomBoxInteractionTests` (aspects, capture anchor centred on the
|
||||
click, move-moves-only-the-box, corner clamp, dims-layer-structure invariant).
|
||||
Full suite 406/406 on a clean run at `948dd08`.
|
||||
@@ -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;
|
||||
|
||||
@@ -288,6 +288,7 @@ public partial class MainViewModel
|
||||
HasBackground = SceneCatalog.HasBackground(name),
|
||||
};
|
||||
EnsureBackground(scene);
|
||||
EnsureZoomLayer(scene);
|
||||
Scenes.Add(scene);
|
||||
StagedScene = scene;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using ytLive.Models;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The zoom box (creator-corrected model, 2026-10-08): right-clicking the live
|
||||
/// preview anchors a CAPTURE window — the box/2 region centred on the click — and
|
||||
/// parks a movable 2x magnifier box on screen. The box shows a live, always-on,
|
||||
/// magnified view of that anchored capture region (the CLICKED area, never re-follows
|
||||
/// the box); dragging it only repositions the box. The content is composited LAST
|
||||
/// (layer n+1) by <see cref="Services.Compositor.SceneCompositor"/> from the already-
|
||||
/// composed frame, so it captures every layer — prove it with the ZoomMagnifierTests
|
||||
/// banded-buffer "box over blue shows green" proof. The transient
|
||||
/// <see cref="ZoomSceneElement"/> carries the box placement + capture anchor; the
|
||||
/// output crop stays the normal tier (the box is NEVER the crop source).
|
||||
/// </summary>
|
||||
public partial class MainViewModel
|
||||
{
|
||||
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:
|
||||
// HorizontalRectangle: 4:3 per creator ("more of a 640x480 aspect
|
||||
// ratio") — NOT the 16:9 of the first cut, which read as a square.
|
||||
h = maxH;
|
||||
w = h * 4.0 / 3.0;
|
||||
break;
|
||||
}
|
||||
|
||||
// The box AND the capture are both centred on the click; the capture window
|
||||
// is the box/2 interior and is FIXED after this call. Each is clamped to the
|
||||
// master 1920x1080 frame independently (an edge click can push the capture
|
||||
// toward centre while the box still sits right at the edge).
|
||||
var x = Math.Clamp(clickX - w / 2, 0, MasterFrameWidth - w);
|
||||
var y = Math.Clamp(clickY - h / 2, 0, MasterFrameHeight - h);
|
||||
var capX = Math.Clamp(clickX - w / 4, 0, MasterFrameWidth - w / 2);
|
||||
var capY = Math.Clamp(clickY - h / 4, 0, MasterFrameHeight - h / 2);
|
||||
_zoomState.X = x;
|
||||
_zoomState.Y = y;
|
||||
_zoomState.Width = w;
|
||||
_zoomState.Height = h;
|
||||
_zoomState.CaptureX = capX;
|
||||
_zoomState.CaptureY = capY;
|
||||
_zoomState.IsActive = true;
|
||||
_zoomState.IsMoving = false;
|
||||
|
||||
var zoomTarget = EnsureZoomLayer(StagedScene);
|
||||
zoomTarget.X = x;
|
||||
zoomTarget.Y = y;
|
||||
zoomTarget.Width = w;
|
||||
zoomTarget.Height = h;
|
||||
zoomTarget.CaptureX = capX;
|
||||
zoomTarget.CaptureY = capY;
|
||||
zoomTarget.ClipShape = _zoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional;
|
||||
zoomTarget.IsZoomActive = true;
|
||||
zoomTarget.IsVisible = true;
|
||||
ScheduleSave();
|
||||
RefreshZoomUi();
|
||||
}
|
||||
|
||||
public void ToggleZoom()
|
||||
{
|
||||
if (_zoomState.IsActive) DismissZoom();
|
||||
}
|
||||
|
||||
public void DismissZoom()
|
||||
{
|
||||
if (!_zoomState.IsActive) return;
|
||||
_zoomState.IsActive = false;
|
||||
_zoomState.IsMoving = false;
|
||||
var zoomTarget = StagedScene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault();
|
||||
if (zoomTarget != null)
|
||||
{
|
||||
zoomTarget.IsZoomActive = false;
|
||||
zoomTarget.IsVisible = false;
|
||||
zoomTarget.CaptureX = 0;
|
||||
zoomTarget.CaptureY = 0;
|
||||
}
|
||||
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
|
||||
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);
|
||||
var zoomTarget = StagedScene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault(e => e.IsZoomActive);
|
||||
if (zoomTarget != null)
|
||||
{
|
||||
zoomTarget.X = _zoomState.X;
|
||||
zoomTarget.Y = _zoomState.Y;
|
||||
// CaptureX/Y are deliberately NOT touched: dragging relocates the box,
|
||||
// never the anchored captured area (creator ruling 2026-10-08).
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Heals a missing zoom placeholder on every scene after a layout
|
||||
/// load: the invisible 0x0 row mirrored by the healed backgrounds. The zoom box
|
||||
/// itself is transient — the placeholder survives so the layer has a seat. The
|
||||
/// compositor ranks the ACTIVE box last regardless of this index.</summary>
|
||||
private void HealZoomLayers()
|
||||
{
|
||||
foreach (var scene in Scenes)
|
||||
EnsureZoomLayer(scene);
|
||||
}
|
||||
|
||||
/// <summary>Gets or inserts the scene's zoom placeholder at z-index 1.</summary>
|
||||
private ZoomSceneElement EnsureZoomLayer(Scene? scene)
|
||||
{
|
||||
var existing = scene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault();
|
||||
if (existing != null) return existing;
|
||||
var zoom = new ZoomSceneElement();
|
||||
_sceneGraph.InsertElement(scene!, Math.Min(1, scene!.Elements.Count), zoom);
|
||||
return zoom;
|
||||
}
|
||||
}
|
||||
@@ -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,30 @@ 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;
|
||||
|
||||
// Zoom activation/move/dismiss and the zoom frame builder live in
|
||||
// MainViewModel.Zoom.cs — the zoom box is a real composited layer (see TASK 52).
|
||||
|
||||
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 +264,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(
|
||||
@@ -369,6 +401,7 @@ public partial class MainViewModel : ViewModelBase
|
||||
AddScene(name);
|
||||
}
|
||||
HealBackgrounds();
|
||||
HealZoomLayers();
|
||||
RefreshHotkeyLabels();
|
||||
|
||||
foreach (var scene in Scenes)
|
||||
@@ -550,7 +583,9 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
|
||||
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
|
||||
private CompositorOptions BuildCompositorOptions()
|
||||
// internal = test seam (the zoom layer must NOT change the output crop — the
|
||||
// box is a composited layer, never the crop source).
|
||||
internal CompositorOptions BuildCompositorOptions()
|
||||
{
|
||||
var quality = SelectedQuality;
|
||||
return new CompositorOptions
|
||||
@@ -562,9 +597,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).
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ areas' docs in their own rows keeps this index small to read.
|
||||
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
|
||||
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
|
||||
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
|
||||
| `MainViewModel.Zoom.cs` | **Zoom box (TASK 52, creator-corrected 2026-10-08)** `ActivateZoom`/`ToggleZoom`/`DismissZoom`/`StartZoomMove`/`UpdateZoomMove`/`EndZoomMove` + `RefreshZoomUi`/`RebuildDimRectsForZoom`. Right-clicking the live preview anchors a FIXED **capture window** (the box/2 region centred on the click) and parks a movable **2x magnifier**. The box shows a live always-on 2x view of the anchored capture; **dragging moves only the box — `CaptureX/Y` are deliberately untouched in `UpdateZoomMove`** (creator ruling). Each shape is centred on the click and clamped independently (box to the master, capture to box/2 windows): Circle = square, VerticalRectangle = 9:16, HorizontalRectangle = **4:3** (w=h·4/3 with h=0.7·1080; NOT 16:9 — first cut read as a square). The transient `ZoomSceneElement` mirrors box + capture; the output compositor draws it **LAST (layer n+1)** from the already-composed frame (`SceneCompositor.BlitZoom`→`ZoomMagnifier`) so it captures EVERY layer. No per-frame builder/cache — the resolver case that built `ZoomFrameBuilder` frames was removed. `BuildCompositorOptions` (internal = test seam) always returns the tier crop — the box is never the crop source. |
|
||||
| `MainViewModel.Background.cs` | Screen backdrop (Live-only by policy): `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture); background model `EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`/`HealBackgrounds`/`StampDefaultBackgroundAsset`; screen capture `ReacquireScreenCaptures`/`ResolveAutoCaptureKey`/`RedesignateBackgroundAsync`/`ClearBackgroundCaptureAsync`/`SetBackgroundCapture`/`OnScreenPreviewBitmapChanged`/`OnFullscreenMonitorChanged`/`RefreshBackgroundAutoCapture`/`BackgroundShowDesktop` (keys on full-screen-game monitor or primary display via `Win32FullScreenDetector`); display picker `Displays`/`SetBackgroundDisplayCommand`; preview fns `ActiveBackgroundImage`/`BackgroundImage`/`UpdateActiveBackground`/`UpdateBackgroundImage`/`RefreshSnapshotsAsync`/`LoadBackgroundImage`/`CreatePlaceholderSnapshot`; `ChangeCaptureCommand`/`RefreshCaptureCommand`/`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`. `ShowPreviewPlaceholder` stays in core. |
|
||||
| `MainViewModel.Webcam.cs` | Webcam identity: `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`; per-scene configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`/`ChangeWebcamAsync` (Windows picker always opens); capture `OnCameraPreviewBitmapChanged`/`OnCameraFailed`; gating `CanAddWebcam`/`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`; size safeguards `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`; `AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`. |
|
||||
| `MainViewModel.Audio.cs` | **Mic bar:** read-only realtime meter `AudioLevel`/`MeterFillWidth`/`MeterBrush` (fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)`, green→yellow→red at 60%/80% zones), `MicVolume` (0.8 default; drives `MicMuted` ⇔ volume 0; unmute flash `BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`), `MicSourceName` (left-justified inside the bar, persists via `LayoutStore.Settings`), `ToggleMicMute`/`PickMicrophone` (`MicPickerDialog`; picked device swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running), status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip` (`Models.MicStatus`: green Connected / yellow Problem / red no-device), capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live. **Game audio bar:** overlaid bottom-center, always visible (show/hide gating removed 2026-08-15); `GameAudioLevel` (loopback via `OnLoopbackLevelChanged`), `GameAudioVolume`/`GameMuted`/`GameMuteText`/`ToggleGameMute`/`PushSystemVolume`, mirror volume flash `SetGameVolumeAdjusting`/`BeginGameVolumeFlash`/`EndGameVolumeFlash`/`CancelGameVolumeFlash`. Mixer handlers `OnMicLevelChanged`/`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`. |
|
||||
|
||||
@@ -737,6 +737,11 @@ parallel software compositor** over the `VideoFrame` (BGRA8) seam, and the XAML
|
||||
renderers must agree: geometry, `UniformToFill` cover-crop, round clip, mirror, border, z-order. Preview
|
||||
stays XAML (editing view); the compositor is the output view.
|
||||
|
||||
- **The `DimRects` shading is itself preview chrome, so it renders as a SIBLING of `CanvasGrid`
|
||||
(above it, below the `ZoomBox`), never a descendant** (2026-10-08, creator ruling): the zoom box's
|
||||
`VisualBrush` samples all of `CanvasGrid`, and if the dims were inside it the box would show the
|
||||
dim shading — especially after the box is dragged away and the fixed capture slides under the dims.
|
||||
|
||||
- **Render the active tier's output rect directly** (`CompositorOptions {SourceRectX/Y/W/H,
|
||||
OutputWidth, OutputHeight}`, fed from `MainViewModel.OutputRect*`): 16:9 = full 1920×1080 1:1; the
|
||||
vertical 9:16 tier = composite the centered 607×1080 crop then bilinear-upscale to 1080×1920.
|
||||
|
||||
@@ -94,11 +94,15 @@ public sealed class LayerReorderPersistenceTests
|
||||
var list = (FocusPreservingListBox)panel.FindName("SourceList")!;
|
||||
list.UpdateLayout();
|
||||
|
||||
// Elements = [Background, ImgA, ImgB, ImgC]. Physically drag ImgC
|
||||
// (row 3) up onto ImgA (row 1): the handlers reorder live elements to
|
||||
// [Background, ImgC, ImgA, ImgB] and EndListDrag must persist that.
|
||||
var from = RowScreenPoint(list, 3);
|
||||
var to = RowScreenPoint(list, 1);
|
||||
// Elements = [Background, Zoom(placeholder, collapsed), ImgA, ImgB,
|
||||
// ImgC]. Physically drag ImgC up onto ImgA: the handlers reorder the
|
||||
// live elements to [Background, Zoom, ImgC, ImgA, ImgB] and EndListDrag
|
||||
// must persist that. Rows are resolved BY ITEM — the collapsed zoom row
|
||||
// shifts container indices, but ItemContainerGenerator maps items to
|
||||
// their (collapsed = zero-height) containers transparently.
|
||||
var sources = vm.StagedScene.Elements.OfType<Source>().ToArray();
|
||||
var from = RowScreenPoint(list, sources.First(s => s.Name == "ImgC"));
|
||||
var to = RowScreenPoint(list, sources.First(s => s.Name == "ImgA"));
|
||||
|
||||
SetCursorPos((int)from.X, (int)from.Y);
|
||||
LetInputSettle(80);
|
||||
@@ -133,13 +137,13 @@ public sealed class LayerReorderPersistenceTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Screen (device-pixel) point one-third into the list row — over the
|
||||
/// name TextBlock, away from the per-row buttons/rename TextBox.</summary>
|
||||
private static Point RowScreenPoint(FocusPreservingListBox list, int index)
|
||||
/// <summary>Screen (device-pixel) point one-third into an item's list row — over
|
||||
/// the name TextBlock, away from the per-row buttons/rename TextBox.</summary>
|
||||
private static Point RowScreenPoint(FocusPreservingListBox list, Models.SceneElement item)
|
||||
{
|
||||
var item = (ListBoxItem)list.ItemContainerGenerator.ContainerFromIndex(index)!;
|
||||
var inList = item.TransformToVisual(list)
|
||||
.Transform(new Point(Math.Min(30, item.ActualWidth - 12), item.ActualHeight / 2));
|
||||
var container = (ListBoxItem)list.ItemContainerGenerator.ContainerFromItem(item)!;
|
||||
var inList = container.TransformToVisual(list)
|
||||
.Transform(new Point(Math.Min(30, container.ActualWidth - 12), container.ActualHeight / 2));
|
||||
return list.PointToScreen(inList);
|
||||
}
|
||||
|
||||
@@ -251,7 +255,12 @@ public sealed class LayerReorderPersistenceTests
|
||||
|
||||
// Restart: a brand-new LayoutStore reading the same DB must restore
|
||||
// the cross-type order exactly — webcam between Image and Web
|
||||
// Resource-0, not appended after all sources.
|
||||
// Resource-0, not appended after all sources. Only persisted elements
|
||||
// count: the transient zoom placeholder (TASK 52) is never written,
|
||||
// so the DB order naturally has no seat for it.
|
||||
expected = elements.Where(e => e is Source or WebcamSceneConfig)
|
||||
.Select(e => e.Name).ToArray();
|
||||
|
||||
Assert.True(PumpUntil(() =>
|
||||
TryReadSceneOrder(tempDb, out var names) && names.SequenceEqual(expected),
|
||||
TimeSpan.FromSeconds(10)),
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
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 (creator-
|
||||
/// corrected model, 2026-10-08): every ZoomView shape procures the right bounding
|
||||
/// box CENTERED on the click and a FIXED capture window (the box/2 region centred
|
||||
/// on the click). Dragging the box relocates it WITHOUT changing the capture — the
|
||||
/// box always shows the same anchored content magnified 2x. The rectangle is 4:3
|
||||
/// ("more of a 640x480 aspect ratio"). The box is a real composited layer drawn
|
||||
/// LAST (a ZoomSceneElement placeholder sits at z-index 1 whose rect + capture
|
||||
/// mirror the box; Kind flips Dynamic only while active), and the output crop
|
||||
/// stays the tier (the box is never the crop source).
|
||||
/// </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_Layer_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();
|
||||
Assert.NotNull(vm.StagedScene);
|
||||
// The healed zoom placeholder sits at z-index 1, inert.
|
||||
var placeholder = (ZoomSceneElement)vm.StagedScene.Elements[1];
|
||||
Assert.False(placeholder.IsZoomActive);
|
||||
Assert.Equal(ElementKind.Static, placeholder.Kind);
|
||||
|
||||
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 of box AND capture.
|
||||
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);
|
||||
Assert.Equal(1000 - vm.ZoomState.Width / 4, vm.ZoomState.CaptureX, 3);
|
||||
Assert.Equal(500 - vm.ZoomState.Height / 4, vm.ZoomState.CaptureY, 3);
|
||||
AssertTrueActiveLayer(vm);
|
||||
|
||||
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);
|
||||
|
||||
// The dims are preview chrome ABOVE the composed content (a sibling of
|
||||
// CanvasGrid, below the ZoomBox) — never inside CanvasGrid — so the zoom
|
||||
// box's sample of CanvasGrid can never pick up the dim shading, even when
|
||||
// the zoom box is dragged so the capture slides under the dims.
|
||||
var previewCanvas = (Canvas)pane.FindName("PreviewCanvas")!;
|
||||
var canvasGrid = (Grid)pane.FindName("CanvasGrid")!;
|
||||
var dimsLayer = (ItemsControl)pane.FindName("DimRectsLayer")!;
|
||||
Assert.Equal(previewCanvas, VisualTreeHelper.GetParent(dimsLayer));
|
||||
for (var parent = VisualTreeHelper.GetParent(dimsLayer); parent != null; parent = VisualTreeHelper.GetParent(parent))
|
||||
Assert.False(ReferenceEquals(parent, canvasGrid), "the dims layer must not be inside CanvasGrid");
|
||||
Assert.True(previewCanvas.Children.IndexOf(dimsLayer) < previewCanvas.Children.IndexOf(zoomBox),
|
||||
"the dims must render below the zoom box, not above it");
|
||||
|
||||
// Phone (vertical rectangle): tall 9:16 box; capture centred on the click.
|
||||
vm.ZoomShape = ZoomShape.VerticalRectangle;
|
||||
vm.ActivateZoom(960, 540);
|
||||
Assert.True(vm.ZoomState.Height > vm.ZoomState.Width);
|
||||
Assert.Equal(9.0 / 16.0, vm.ZoomState.Width / vm.ZoomState.Height, 3);
|
||||
Assert.Equal(960 - vm.ZoomState.Width / 2, vm.ZoomState.X, 3);
|
||||
Assert.Equal(540 - vm.ZoomState.Height / 2, vm.ZoomState.Y, 3);
|
||||
Assert.Equal(960 - vm.ZoomState.Width / 4, vm.ZoomState.CaptureX, 3);
|
||||
Assert.Equal(540 - vm.ZoomState.Height / 4, vm.ZoomState.CaptureY, 3);
|
||||
AssertTrueActiveLayer(vm);
|
||||
|
||||
window.UpdateLayout();
|
||||
Assert.Null(zoomBox.Clip);
|
||||
Assert.Equal(Visibility.Collapsed, circleOutline.Visibility);
|
||||
Assert.Equal(Visibility.Visible, rectOutline.Visibility);
|
||||
|
||||
// Tablet (horizontal rectangle): 4:3 wide box (creator: "more of a
|
||||
// 640x480 aspect ratio" — the first-cut 16:9 read as a square).
|
||||
vm.ZoomShape = ZoomShape.HorizontalRectangle;
|
||||
vm.ActivateZoom(960, 540);
|
||||
Assert.True(vm.ZoomState.Width > vm.ZoomState.Height);
|
||||
Assert.Equal(4.0 / 3.0, vm.ZoomState.Width / vm.ZoomState.Height, 3);
|
||||
Assert.Equal(960 - vm.ZoomState.Width / 2, vm.ZoomState.X, 3);
|
||||
Assert.Equal(540 - vm.ZoomState.Height / 2, vm.ZoomState.Y, 3);
|
||||
Assert.Equal(960 - vm.ZoomState.Width / 4, vm.ZoomState.CaptureX, 3);
|
||||
Assert.Equal(540 - vm.ZoomState.Height / 4, vm.ZoomState.CaptureY, 3);
|
||||
AssertTrueActiveLayer(vm);
|
||||
|
||||
window.UpdateLayout();
|
||||
Assert.Null(zoomBox.Clip);
|
||||
Assert.Equal(Visibility.Collapsed, circleOutline.Visibility);
|
||||
Assert.Equal(Visibility.Visible, rectOutline.Visibility);
|
||||
|
||||
// THE core semantic: dragging the box moves only the box. The capture —
|
||||
// and therefore the content the box shows — never changes. (Box is still
|
||||
// the un-clamped tablet box [456,162] at this point with room to move.)
|
||||
var boxX = vm.ZoomState.X;
|
||||
var boxY = vm.ZoomState.Y;
|
||||
var captureX = vm.ZoomState.CaptureX;
|
||||
var captureY = vm.ZoomState.CaptureY;
|
||||
vm.StartZoomMove(800, 400);
|
||||
vm.UpdateZoomMove(1200, 600);
|
||||
vm.EndZoomMove();
|
||||
Assert.True(Math.Abs(vm.ZoomState.X - boxX) >= 100, "the box must have moved");
|
||||
Assert.True(Math.Abs(vm.ZoomState.Y - boxY) >= 100, "the box must have moved");
|
||||
Assert.Equal(captureX, vm.ZoomState.CaptureX);
|
||||
Assert.Equal(captureY, vm.ZoomState.CaptureY);
|
||||
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene.Elements[1]);
|
||||
Assert.Equal(vm.ZoomState.X, zoom.X, 3);
|
||||
Assert.Equal(vm.ZoomState.Y, zoom.Y, 3);
|
||||
Assert.Equal(captureX, zoom.CaptureX, 3);
|
||||
Assert.Equal(captureY, zoom.CaptureY, 3);
|
||||
|
||||
// Clamp keeps the box AND the capture fully inside the 1920x1080 master
|
||||
// even for a corner click that would otherwise overhang both.
|
||||
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);
|
||||
Assert.True(vm.ZoomState.CaptureX >= 0 && vm.ZoomState.CaptureY >= 0);
|
||||
Assert.True(vm.ZoomState.CaptureX + vm.ZoomState.Width / 2 <= frameW);
|
||||
Assert.True(vm.ZoomState.CaptureY + vm.ZoomState.Height / 2 <= frameH);
|
||||
AssertTrueActiveLayer(vm);
|
||||
|
||||
// The box is a composited layer, NEVER the crop: output stays the tier.
|
||||
var opts = vm.BuildCompositorOptions();
|
||||
Assert.True(opts.SourceRectWidth > vm.ZoomState.Width);
|
||||
Assert.Equal((int)Math.Round(vm.OutputRectWidth), opts.SourceRectWidth);
|
||||
Assert.Equal((int)Math.Round(vm.OutputRectHeight), opts.SourceRectHeight);
|
||||
|
||||
// Dismiss restores the inert placeholder (Kind back to Static).
|
||||
vm.DismissZoom();
|
||||
Assert.False(vm.IsZoomActive);
|
||||
Assert.False(placeholder.IsZoomActive);
|
||||
Assert.False(placeholder.IsVisible);
|
||||
Assert.Equal(ElementKind.Static, placeholder.Kind);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { System.IO.File.Delete(tempDb); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertTrueActiveLayer(MainViewModel vm)
|
||||
{
|
||||
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene!.Elements[1]);
|
||||
Assert.True(zoom.IsZoomActive);
|
||||
Assert.True(zoom.IsVisible);
|
||||
Assert.Equal(ElementKind.Dynamic, zoom.Kind);
|
||||
Assert.Equal(vm.ZoomState.X, zoom.X, 3);
|
||||
Assert.Equal(vm.ZoomState.Y, zoom.Y, 3);
|
||||
Assert.Equal(vm.ZoomState.Width, zoom.Width, 3);
|
||||
Assert.Equal(vm.ZoomState.Height, zoom.Height, 3);
|
||||
Assert.Equal(vm.ZoomState.CaptureX, zoom.CaptureX, 3);
|
||||
Assert.Equal(vm.ZoomState.CaptureY, zoom.CaptureY, 3);
|
||||
Assert.Equal(vm.ZoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional, zoom.ClipShape);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using ytLive.Services.Compositor;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves the zoom box's pixels match the creator-corrected model (2026-10-08):
|
||||
/// the box is a GENUINE 2x magnified view of a FIXED capture window (the box/2
|
||||
/// region centred on the click) sampled from the ALREADY-COMPOSITED frame — i.e.
|
||||
/// every layer, and NOT what happens to sit under the box. The money test below
|
||||
/// puts the box over the blue half of the frame while its capture sits in the
|
||||
/// green half (with a red webcam square + a white 2px stripe inside): a 1x
|
||||
/// loupe, a box-following loupe, or a backdrop-only sample all fail hard.
|
||||
/// </summary>
|
||||
public sealed class ZoomMagnifierTests
|
||||
{
|
||||
private const int W = 1920;
|
||||
private const int H = 1080;
|
||||
|
||||
/// <summary>1920x1080 opaque "composited scene": blue left half (x<960),
|
||||
/// green right half, plus a red 40x40 webcam square and a white 2px stripe
|
||||
/// inside the capture region — the extra "layers" the box must capture.</summary>
|
||||
private static byte[] ComposedFrame()
|
||||
{
|
||||
var buf = new byte[W * H * 4];
|
||||
for (var y = 0; y < H; y++)
|
||||
{
|
||||
for (var x = 0; x < W; x++)
|
||||
{
|
||||
var i = (y * W + x) * 4;
|
||||
buf[i] = 0; buf[i + 1] = 0; buf[i + 2] = 0; buf[i + 3] = 255; // black
|
||||
if (x >= 960) { buf[i] = 0; buf[i + 1] = 255; buf[i + 2] = 0; } // green
|
||||
else { buf[i] = 255; buf[i + 1] = 0; buf[i + 2] = 0; } // blue
|
||||
if (x >= 1000 && x < 1040 && y >= 520 && y < 560) { buf[i] = 0; buf[i + 1] = 0; buf[i + 2] = 255; } // red webcam square
|
||||
if (x >= 1100 && x < 1102) { buf[i] = 255; buf[i + 1] = 255; buf[i + 2] = 255; } // white stripe
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static byte[] Pixel(byte[] buf, int x, int y)
|
||||
{
|
||||
var i = (y * W + x) * 4;
|
||||
return new[] { buf[i], buf[i + 1], buf[i + 2] };
|
||||
}
|
||||
|
||||
private static void AssertColor(byte[] buf, int x, int y, byte b, byte g, byte r)
|
||||
{
|
||||
var p = Pixel(buf, x, y);
|
||||
Assert.Equal(new[] { b, g, r }, p);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Magnify_BoxOverBlue_ShowsFixedGreenCapture_WithAllLayers_At2x()
|
||||
{
|
||||
var buf = ComposedFrame();
|
||||
|
||||
// Click at (1080, 540). Box 400x200 centred on the click → box [880,1280)x[440,640).
|
||||
// Capture = box/2 → [980,1180)x[490,590), entirely in the GREEN half and holding
|
||||
// the red webcam square [1000,1040)x[520,560) and the white stripe [1100,1102).
|
||||
ZoomMagnifier.MagnifyInto(buf, W, H,
|
||||
boxX: 880, boxY: 440, boxW: 400, boxH: 200,
|
||||
capX: 980, capY: 490, round: false);
|
||||
|
||||
// The whole box shows the CAPTURE (green + its layer content), not the frame
|
||||
// beneath it — the box's left quarter sits over the BLUE half. Any blue inside
|
||||
// the box means the sampler followed the box (the loupe bug). Green/red/white
|
||||
// are all fine (edges blend at the bright stripes), blue (B≫G) is the crime.
|
||||
for (var y = 440; y < 640; y++)
|
||||
{
|
||||
for (var x = 880; x < 1280; x++)
|
||||
{
|
||||
var p = Pixel(buf, x, y);
|
||||
Assert.True(p[1] >= p[0],
|
||||
$"box pixel ({x},{y}) = ({p[0]},{p[1]},{p[2]}) — blue means the sampler followed the box, never the capture");
|
||||
}
|
||||
}
|
||||
|
||||
// The red webcam square is inside the capture → it is captured (all layers!).
|
||||
// Capture-local [20,60)x[30,70) maps 2x to box [920,1000)x[500,580).
|
||||
for (var x = 925; x < 995; x++)
|
||||
for (var y = 505; y < 575; y++)
|
||||
AssertColor(buf, x, y, 0, 0, 255);
|
||||
AssertColor(buf, 1010, 570, 0, 255, 0); // past the square → green
|
||||
|
||||
// The 2x proof: the 2px white stripe at capture [1100,1102) must land at box
|
||||
// [1120,1121] (offset*2) — a 1x sample would leave it near capture space and a
|
||||
// box-following sample would miss it entirely. Probe the interior (solid white)
|
||||
// and either side (solid green), skipping bilinear edge blends.
|
||||
AssertColor(buf, 1120, 540, 255, 255, 255);
|
||||
AssertColor(buf, 1121, 540, 255, 255, 255);
|
||||
AssertColor(buf, 1115, 540, 0, 255, 0);
|
||||
AssertColor(buf, 1124, 540, 0, 255, 0);
|
||||
|
||||
// Outside the box the frame is untouched.
|
||||
AssertColor(buf, 400, 540, 255, 0, 0); // still blue (left half)
|
||||
AssertColor(buf, 1500, 540, 0, 255, 0); // still green
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Magnify_Round_Clips_To_Circle_Leaving_Corners_Untouched()
|
||||
{
|
||||
var buf = ComposedFrame();
|
||||
|
||||
// Same geometry as the money test but circle: radius = min(400,200)/2 = 100.
|
||||
ZoomMagnifier.MagnifyInto(buf, W, H,
|
||||
boxX: 880, boxY: 440, boxW: 400, boxH: 200,
|
||||
capX: 980, capY: 490, round: true);
|
||||
|
||||
// Box corners are outside the circle: the underlying blue frame shows through.
|
||||
AssertColor(buf, 884, 444, 255, 0, 0);
|
||||
AssertColor(buf, 884, 636, 255, 0, 0);
|
||||
AssertColor(buf, 1276, 444, 0, 255, 0); // right side over green stays green
|
||||
// Box centre is inside the circle: captured green.
|
||||
AssertColor(buf, 1080, 540, 0, 255, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user