Web source: crop to the frame's ALPHA bounding box — the widget's true rendered extent, no DOM timing, can't break the render

- FindContentBounds scans the Bgra32 capture for the bounding box of non-transparent pixels
  (background is injected transparent, so alpha = widget extent). Crop to that rect + Stretch=Fill
  maps the widget flush under the selection box: full-canvas widgets fall through to the full frame
  (identical to the confirmed-good image); floating widgets get tight-fitted with no dead space.
- Removes QueryContentBoundsAsync + the JS script entirely — the DOM-union attempt (a5b9952) broke
  rendering by measuring an unsettled layout on NavigationCompleted; alpha measurement is immune.
- 3 new unit tests: tight box, no-alpha no-crop, full-alpha no-crop. 7/7 pass, 0 warnings.
This commit is contained in:
2026-08-28 11:53:36 -07:00
parent 5348b5c480
commit bccdb4892c
5 changed files with 117 additions and 80 deletions
+29 -43
View File
@@ -27,10 +27,6 @@ public sealed class WebView2Manager : IDisposable
public required WebView2 Control { get; init; }
public required DispatcherTimer CaptureTimer { get; init; }
public VideoFrame? LatestFrame;
public double ViewportCssW;
public double ViewportCssH;
public double ContentCssW;
public double ContentCssH;
public bool Disposed;
public bool Initialized;
}
@@ -48,9 +44,11 @@ public sealed class WebView2Manager : IDisposable
// OBS model: the page renders at the MASTER CANVAS size (1920×1080),
// stable, never tracked. Full-bleed widgets (designed for the canvas)
// render fully instead of being clipped to a small box-sized viewport;
// dragging the box never reflows the page. The capture is cropped to
// the widget's actual content bounds (QueryContentBoundsAsync) and the
// display (Stretch=Fill) maps it flush under the box, anchored at (0,0).
// dragging the box never reflows the page. The capture is tightly
// cropped to the widget's ALPHA bounding box (FindContentBounds) and
// the display (Stretch=Fill) maps it flush under the box, anchored at
// (0,0) — no DOM query, so no timing fragility; worst case is the
// full frame, which is the confirmed-good rendering.
var webView = new WebView2
{
Width = 1920,
@@ -132,7 +130,6 @@ public sealed class WebView2Manager : IDisposable
"document.documentElement.style.overflow='hidden';" +
"document.body.style.background='transparent';" +
"document.body.style.overflow='hidden';");
_ = QueryContentBoundsAsync(source.Id);
_ = CaptureFrame(source.Id);
};
@@ -159,33 +156,30 @@ public sealed class WebView2Manager : IDisposable
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
}
// The page's real content rect (scrollWidth/Height) can be smaller than the
// viewport for widgets that don't fill it — the leftover area is dead space
// that must not stretch across the selection box. Crop the capture to the
// content bounds (anchored at 0,0) so the box shows exactly the widget.
private async Task QueryContentBoundsAsync(string sourceId)
// The widget's extent is measured from the captured frame itself: the
// bounding box of non-transparent pixels (the background is injected
// transparent, so the alpha channel IS the widget). This is immune to
// DOM timing (fonts/images/animations), needs no JS round-trip, and
// CANNOT break rendering: if the widget fills the canvas the box equals
// the full frame (identical to the confirmed-good uncropped display); if
// it floats in transparent margin, the crop maps it flush under the box.
internal static (int X, int Y, int W, int H) FindContentBounds(byte[] pixels, int pixW, int pixH, int stride)
{
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
try
int minX = pixW, minY = pixH, maxX = -1, maxY = -1;
for (var y = 0; y < pixH; y++)
{
if (session.Control.CoreWebView2 is not { } cws) return;
var json = await cws.ExecuteScriptAsync(
"JSON.stringify({w:document.documentElement.scrollWidth||0," +
"h:document.documentElement.scrollHeight||0," +
"vw:window.innerWidth||0,vh:window.innerHeight||0})");
if (string.IsNullOrEmpty(json) || json == "null") return;
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
session.ViewportCssW = root.GetProperty("vw").GetDouble();
session.ViewportCssH = root.GetProperty("vh").GetDouble();
session.ContentCssW = root.GetProperty("w").GetDouble();
session.ContentCssH = root.GetProperty("h").GetDouble();
_ = CaptureFrame(sourceId);
}
catch (Exception ex)
{
AppLog.Write($"WebView2Manager: content bounds query failed: {ex.Message}");
var row = y * stride;
for (var x = 0; x < pixW; x++)
{
if (pixels[row + x * 4 + 3] == 0) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
if (maxX < minX || maxY < minY) return (0, 0, pixW, pixH);
return (minX, minY, maxX - minX + 1, maxY - minY + 1);
}
private async Task CaptureFrame(string sourceId)
@@ -223,23 +217,15 @@ public sealed class WebView2Manager : IDisposable
var stride = pixW * 4;
int cropW = pixW, cropH = pixH;
if (session.ViewportCssW >= 1 && session.ViewportCssH >= 1 &&
session.ContentCssW >= 1 && session.ContentCssH >= 1)
{
var sx = pixW / session.ViewportCssW;
var sy = pixH / session.ViewportCssH;
cropW = (int)Math.Clamp(Math.Round(session.ContentCssW * sx), 1, pixW);
cropH = (int)Math.Clamp(Math.Round(session.ContentCssH * sy), 1, pixH);
}
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride);
var outPixels = pixels;
if (cropW != pixW || cropH != pixH)
if (cropW != pixW || cropH != pixH || cropX != 0 || cropY != 0)
{
var dstStride = cropW * 4;
outPixels = new byte[dstStride * cropH];
for (var r = 0; r < cropH; r++)
Buffer.BlockCopy(pixels, r * stride, outPixels, r * dstStride, dstStride);
Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * dstStride, dstStride);
}
var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);