c01206fb8a
The ~30Hz CapturePreviewAsync PNG poll capped real cadence at ~20Hz (35–165ms full-HD encode+decode), so a 60fps widget still juddered at ~1/6 speed. Replaced polling with frame-driven capture of the composition controller's root visual — the mechanism WebView2CompositionControl and Flutter's webview_windows use (graphics_context.cc captures the root surface_ visual via CreateGraphicsCaptureItemFromVisual; reference: github.com/microsoft/Windows.UI.Composition.WinUI / flutter-internal webview_windows). Frames now arrive at the renderer's own pace; capture memory is epoch'd ring reuse + one crop-sized shared WriteableBitmap. New Services/WebCaptureFrameSource.cs owns GraphicsCaptureItem + free- threaded Direct3D11CaptureFramePool + session (Straight alpha readback, per-frame FindContentBounds → CropBounds). WebView2Manager reworked around per-session composition controllers + one UI-thread Compositor created via the CoreMessaging CreateDispatcherQueueController P/Invoke (the 19041 projection lacks CreateOnCurrentThread); internal seam ctor (Dispatcher, Func<string,IScreenCaptureSource>?) for hermetic tests. CaptureScheduler.cs deleted; the three SetCaptureInterval cadence hooks removed; InitWebView2() moved from MainWindow ctor to Loaded (a parent HWND must exist for the composition controller); the hidden WebViewHostPanel overlay deleted. TransparentBackgroundScript unchanged. Tests: WebView2ManagerTests reworked — 4 control-size + scheduler tests dropped, FindContentBounds tests moved to WebCaptureFrameSource, ONE integration test (Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds) drives the seam with a FakeWebSource + background-STA DispatcherPump. Suite 293/293, 0 warnings. NOTE: composition path NOT yet verified on a device — the take is the next step. Web work committed locally only (no push per standing rule). Docs same-commit: ai.md Slice 14 + supersede marker on Slice 11, HANDOFF, MyMistakes (CoreMessaging DQ + namespace-landmine recipe), TASK 17, Controls/ViewModels/Services indexes.
145 lines
5.6 KiB
C#
145 lines
5.6 KiB
C#
using System;
|
|
using System.Threading;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media;
|
|
using System.Windows.Shapes;
|
|
using System.Windows.Threading;
|
|
using Microsoft.Data.Sqlite;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.ViewModels;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// Reproduces the real MainWindow preview to check the round-clip corner
|
|
/// handle: whether a click at the corner actually stays inside the preview
|
|
/// (doesn't fall through and deselect the source) and whether the round clip
|
|
/// renders as a circle rather than an oval.
|
|
/// </summary>
|
|
[Collection("RealApp")]
|
|
public sealed class RoundClipInteractionTests
|
|
{
|
|
private readonly RealAppHost _app;
|
|
|
|
public RoundClipInteractionTests(RealAppHost app)
|
|
{
|
|
_app = app;
|
|
}
|
|
|
|
[Fact]
|
|
public void Round_Clip_Corner_Is_Grabbable_And_Shape_Is_Circle()
|
|
{
|
|
_app.Run(Run);
|
|
}
|
|
|
|
|
|
private void Run()
|
|
{
|
|
// Never let the real MainWindow read/write the user's actual layout DB —
|
|
// Shutdown() saves the layout, which would persist these test sources
|
|
// over the real ones. Point it at a throwaway temp DB instead.
|
|
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();
|
|
|
|
var scene = vm.StagedScene!;
|
|
var webcam = new WebcamSceneConfig
|
|
{
|
|
WebcamId = "test-camera",
|
|
Name = "Webcam",
|
|
X = 1408,
|
|
Y = 778,
|
|
Width = 480,
|
|
Height = 270,
|
|
};
|
|
scene.Elements.Add(webcam);
|
|
vm.SelectedElement = webcam;
|
|
window.UpdateLayout();
|
|
|
|
// PreviewGrid/CanvasGrid moved into Controls/PreviewPane.xaml during the
|
|
// component refactor — a UserControl owns its own namescope, so window.FindName
|
|
// returns null for them. Resolve through the pane (found by its window-level name).
|
|
var pane = (System.Windows.Controls.UserControl)window.FindName("PreviewPane")!;
|
|
var previewGrid = (Grid)pane.FindName("PreviewGrid")!;
|
|
var canvasGrid = (Grid)pane.FindName("CanvasGrid")!;
|
|
var toWindow = canvasGrid.TransformToVisual(window);
|
|
|
|
var corner = new Point(webcam.X + webcam.Width, webcam.Y + webcam.Height);
|
|
|
|
foreach (var shape in new[] { ClipShape.Traditional, ClipShape.Round })
|
|
{
|
|
webcam.ClipShape = shape;
|
|
window.UpdateLayout();
|
|
|
|
var cornerInWindow = toWindow.Transform(corner);
|
|
|
|
// A click on the corner must land inside the preview (descendant
|
|
// of PreviewGrid); otherwise Window_PreviewMouseLeftButtonDown
|
|
// deselects the source and the handle can never be grabbed.
|
|
// UIElement.InputHitTest IS the input pipeline — what Mouse uses.
|
|
// Do NOT use VisualTreeHelper.HitTest here: it ignores IsHitTestVisible
|
|
// input semantics (the old invisible WebView2 host overlay for web
|
|
// sources used to win every visual hit — the wrong-API false bug this
|
|
// test "found" for months, root-caused 2026-09-01; the overlay is gone
|
|
// with the composition-based web render but the API lesson stays).
|
|
var hit = window.InputHitTest(cornerInWindow) as DependencyObject;
|
|
var grabbable = hit != null && IsDescendantOf(hit, previewGrid);
|
|
|
|
Assert.True(grabbable,
|
|
$"{shape}: corner click fell through to '{hit?.GetType().Name ?? "null"}' " +
|
|
"so the source gets deselected before the resize handler runs");
|
|
}
|
|
|
|
// The round clip must render as a circle (square bounding box), not an oval.
|
|
webcam.ClipShape = ClipShape.Round;
|
|
window.UpdateLayout();
|
|
var clip = FindRoundClip(window);
|
|
Assert.NotNull(clip);
|
|
var geometry = Assert.IsType<EllipseGeometry>(clip!.Clip);
|
|
Assert.Equal(0.5, geometry.Center.X, 1.0);
|
|
Assert.Equal(0.5, geometry.Center.Y, 1.0);
|
|
Assert.Equal(0.5, geometry.RadiusX, 1.0);
|
|
Assert.Equal(0.5, geometry.RadiusY, 1.0);
|
|
Assert.Equal(clip.ActualWidth, clip.ActualHeight, 1.0);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
MainViewModel.LayoutPathOverride = null;
|
|
SqliteConnection.ClearAllPools();
|
|
try { System.IO.File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
|
}
|
|
}
|
|
|
|
private static Image? FindRoundClip(Window window)
|
|
{
|
|
return Walk(window, element => element is Image i && i.IsVisible && i.Clip is EllipseGeometry) as Image;
|
|
}
|
|
|
|
private static DependencyObject? Walk(DependencyObject parent, Func<DependencyObject, bool> predicate)
|
|
{
|
|
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
|
|
{
|
|
var child = VisualTreeHelper.GetChild(parent, i);
|
|
if (predicate(child)) return child;
|
|
var found = Walk(child, predicate);
|
|
if (found != null) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
|
|
{
|
|
while (child != null && !ReferenceEquals(child, ancestor))
|
|
child = VisualTreeHelper.GetParent(child);
|
|
return child != null;
|
|
}
|
|
}
|