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.
231 lines
8.9 KiB
C#
231 lines
8.9 KiB
C#
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows.Threading;
|
|
using System.Windows.Media.Imaging;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// WebView2Manager owns the frame-driven web sources (composition-captured, see
|
|
/// Services/WebCaptureFrameSource.cs). The controller/visual/GraphicCaptureItem
|
|
/// layer is WinRT and only exercisable on Windows at runtime, so these tests pin
|
|
/// the pure pieces (transparency script, the alpha-bbox math) and the ONE
|
|
/// integration test drives the session plumbing with a fake source: registration,
|
|
/// crop-stamped frames, the shared cropped preview bitmap, and coalescing to the
|
|
/// latest frame.
|
|
/// </summary>
|
|
public class WebView2ManagerTests
|
|
{
|
|
private sealed class FakeWebSource : IScreenCaptureSource
|
|
{
|
|
public string Key { get; }
|
|
public event Action<VideoFrame>? FrameAvailable;
|
|
|
|
public FakeWebSource(string key) => Key = key;
|
|
|
|
public Task StartAsync() => Task.CompletedTask;
|
|
public Task StopAsync() => Task.CompletedTask;
|
|
|
|
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
|
|
}
|
|
|
|
// A real Dispatcher pumping on a background STA thread, so the manager's
|
|
// BeginInvoke queue can be drained deterministically from the test thread
|
|
// (same harness as ScreenCaptureManagerTests).
|
|
private sealed class DispatcherPump : IDisposable
|
|
{
|
|
private readonly Thread _thread;
|
|
private readonly ManualResetEventSlim _ready = new(false);
|
|
private Dispatcher? _dispatcher;
|
|
|
|
public Dispatcher Dispatcher => _dispatcher!;
|
|
|
|
public DispatcherPump()
|
|
{
|
|
_thread = new Thread(() =>
|
|
{
|
|
_dispatcher = Dispatcher.CurrentDispatcher;
|
|
_ready.Set();
|
|
Dispatcher.Run();
|
|
});
|
|
_thread.IsBackground = true;
|
|
_thread.SetApartmentState(ApartmentState.STA);
|
|
_thread.Start();
|
|
_ready.Wait();
|
|
}
|
|
|
|
// Queued behind anything already posted at Render/higher, so pending
|
|
// bitmap copies run first.
|
|
public void Drain() => Dispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle);
|
|
|
|
public void Dispose()
|
|
{
|
|
Dispatcher.InvokeShutdown();
|
|
_thread.Join(3000);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TransparentBackgroundScript_Is_A_Important_Element_Wide_Wipe()
|
|
{
|
|
// The regression the inline style lost to: a widget repainting a container (or
|
|
// html/body) opaque after connect re-backs the capture → "black box" in the
|
|
// recording while early frames stayed transparent. The OBS-validated formula is a
|
|
// pre-parse <style> with !important outranking every page rule (OBS Forums 2016).
|
|
var script = WebView2Manager.TransparentBackgroundScript;
|
|
|
|
Assert.Contains("background-color:transparent!important", script);
|
|
Assert.Contains("background-image:none!important", script);
|
|
Assert.Contains("html,body,html *", script);
|
|
Assert.Contains("document.createElement('style')", script);
|
|
Assert.DoesNotContain(".style.background=", script);
|
|
}
|
|
|
|
[Fact]
|
|
public void FindContentBounds_Returns_Bounding_Box_Of_NonTransparent_Pixels()
|
|
{
|
|
const int w = 10, h = 6;
|
|
var pixels = new byte[w * h * 4];
|
|
for (int yy = 2; yy <= 4; yy++)
|
|
for (int xx = 3; xx <= 6; xx++)
|
|
{
|
|
var i = (yy * w + xx) * 4;
|
|
pixels[i] = 255; // B
|
|
pixels[i + 1] = 0; // G
|
|
pixels[i + 2] = 0; // R
|
|
pixels[i + 3] = 255; // A
|
|
}
|
|
|
|
var (x, y, bw, bh) = WebCaptureFrameSource.FindContentBounds(pixels, w, h, w * 4);
|
|
|
|
Assert.Equal((3, 2, 4, 3), (x, y, bw, bh));
|
|
}
|
|
|
|
[Fact]
|
|
public void FindContentBounds_No_Alpha_Means_No_Crop()
|
|
{
|
|
const int w = 10, h = 6;
|
|
var pixels = new byte[w * h * 4];
|
|
|
|
var (x, y, bw, bh) = WebCaptureFrameSource.FindContentBounds(pixels, w, h, w * 4);
|
|
|
|
Assert.Equal((0, 0, w, h), (x, y, bw, bh));
|
|
}
|
|
|
|
[Fact]
|
|
public void FindContentBounds_Full_Opacity_Means_No_Crop()
|
|
{
|
|
const int w = 10, h = 6;
|
|
var pixels = new byte[w * h * 4];
|
|
for (var i = 3; i < pixels.Length; i += 4)
|
|
pixels[i] = 255;
|
|
|
|
var (x, y, bw, bh) = WebCaptureFrameSource.FindContentBounds(pixels, w, h, w * 4);
|
|
|
|
Assert.Equal((0, 0, w, h), (x, y, bw, bh));
|
|
}
|
|
|
|
// The ONE integration test for this change: a registered session creates one
|
|
// shared WriteableBitmap sized to the widget's CropBounds, publishes it once,
|
|
// and back-to-back frames coalesce to the latest (a single pending UI copy,
|
|
// mirror of ScreenCaptureManager) — the frame-driven contract the PNG-poll
|
|
// redesign replaced the CaptureScheduler cadence with.
|
|
[Fact]
|
|
public void Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds()
|
|
{
|
|
using var pump = new DispatcherPump();
|
|
FakeWebSource? fake = null;
|
|
using var manager = new WebView2Manager(pump.Dispatcher, key => fake = new FakeWebSource(key));
|
|
|
|
WriteableBitmap? published = null;
|
|
var publishedCount = 0;
|
|
manager.PreviewBitmapChanged += (_, bitmap) => { published = bitmap; publishedCount++; };
|
|
|
|
var source = new Source { Type = SourceType.WebSource, Width = 640, Height = 480 };
|
|
manager.Register(source);
|
|
Assert.NotNull(fake); // the seam instantiated + started the source inline
|
|
Assert.Null(manager.GetLatestFrame(source.Id)); // nothing pumped yet
|
|
|
|
// 4x4 canvas; alpha 255 only in bbox (1,1)-(2,2) → the widget crops to (1,1,2,2).
|
|
var first = WidgetFrame(4, 4, frameSeed: 42);
|
|
fake!.Pump(first);
|
|
pump.Drain();
|
|
|
|
Assert.NotNull(published);
|
|
Assert.Equal(1, publishedCount); // one shared bitmap, published once
|
|
var dims = pump.Dispatcher.Invoke(() => new[] { published!.PixelWidth, published.PixelHeight });
|
|
Assert.Equal(new[] { 2, 2 }, dims);
|
|
|
|
var latest = manager.GetLatestFrame(source.Id);
|
|
Assert.NotNull(latest);
|
|
Assert.Equal((1, 1, 2, 2), latest!.CropBounds); // the source stamps the alpha bbox
|
|
Assert.False(latest.IsOpaque); // straight-alpha web contract
|
|
|
|
var previewBytes = pump.Dispatcher.Invoke(() =>
|
|
{
|
|
var bytes = new byte[16];
|
|
published!.CopyPixels(new System.Windows.Int32Rect(0, 0, 2, 2), bytes, 8, 0);
|
|
return bytes;
|
|
});
|
|
// The preview is a DENSE 2x2 rect (bbox 1,1..2,2 sliced out of the 4x4
|
|
// canvas) — rows y=1 and y=2, columns x=1..2.
|
|
var expected = CropBytes(first, w: 4, x0: 1, y0: 1, w2: 2, h2: 2);
|
|
Assert.Equal(expected, previewBytes);
|
|
|
|
// Second + third back-to-back: only the latest lands in the preview bitmap.
|
|
var second = WidgetFrame(4, 4, frameSeed: 77);
|
|
var third = WidgetFrame(4, 4, frameSeed: 99);
|
|
fake.Pump(second);
|
|
fake.Pump(third);
|
|
pump.Drain();
|
|
|
|
Assert.Equal(1, publishedCount); // same crop box → no new bitmap
|
|
Assert.Same(third, manager.GetLatestFrame(source.Id));
|
|
var coalesced = pump.Dispatcher.Invoke(() =>
|
|
{
|
|
var bytes = new byte[16];
|
|
published!.CopyPixels(new System.Windows.Int32Rect(0, 0, 2, 2), bytes, 8, 0);
|
|
return bytes;
|
|
});
|
|
Assert.Equal(CropBytes(third, 4, 1, 1, 2, 2), coalesced);
|
|
}
|
|
|
|
/// <summary>Dense little-endian slice of a w-wide frame for the rect
|
|
/// (x0, y0)-(x0+w2-1, y0+h2-1); matches the manager's crop readback so the
|
|
/// byte comparison is against the same layout, not the full frame stride.</summary>
|
|
private static byte[] CropBytes(VideoFrame frame, int w, int x0, int y0, int w2, int h2)
|
|
{
|
|
var src = frame.BgraPixels;
|
|
var dst = new byte[w2 * h2 * 4];
|
|
for (var y = 0; y < h2; y++)
|
|
System.Buffer.BlockCopy(src, ((y0 + y) * w + x0) * 4, dst, y * w2 * 4, w2 * 4);
|
|
return dst;
|
|
}
|
|
|
|
/// <summary>A 4x4 full canvas where the widget content is bbox (1,1,2,2):
|
|
/// alpha 255 there, 0 elsewhere; the content pixels carry a seed so a
|
|
/// coalesce mix-up is visible in the byte assertion.</summary>
|
|
private static VideoFrame WidgetFrame(int w, int h, byte frameSeed)
|
|
{
|
|
var pixels = new byte[w * h * 4];
|
|
for (var y = 0; y < h; y++)
|
|
for (var x = 0; x < w; x++)
|
|
{
|
|
if (x < 1 || x > 2 || y < 1 || y > 2) continue;
|
|
var i = (y * w + x) * 4;
|
|
pixels[i] = frameSeed; // B
|
|
pixels[i + 1] = (byte)(frameSeed / 3); // G
|
|
pixels[i + 2] = 0; // R
|
|
pixels[i + 3] = 255; // A
|
|
}
|
|
return new VideoFrame(w, h, pixels)
|
|
{
|
|
IsOpaque = false,
|
|
CropBounds = (1, 1, 2, 2),
|
|
};
|
|
}
|
|
} |