From ed9d7c1ffc5880cb0158f2a7937128220a94487e Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 8 Sep 2026 11:17:05 -0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20CropBounds=20metadata=20+=20Fill-st?= =?UTF-8?q?yle=20scaling=20in=20compositor=20=E2=80=94=20transparent=20mar?= =?UTF-8?q?gins=20preserved,=20widget=20fills=20element=20rect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Services/Compositor/SceneCompositor.cs | 55 ++++++++++++++++++-------- Services/VideoFrame.cs | 7 ++++ Services/WebView2Manager.cs | 6 ++- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs index fa1a52a..8b12fdf 100644 --- a/Services/Compositor/SceneCompositor.cs +++ b/Services/Compositor/SceneCompositor.cs @@ -420,9 +420,42 @@ public sealed class SceneCompositor VideoFrame src, float opacity, bool isRound, bool isMirror) { if (ew <= 0 || eh <= 0) return; - var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height); - var drawnW = src.Width * scale; - var drawnH = src.Height * scale; + + float scale, ox, oy; + int srcCropX = 0, srcCropY = 0; + int w = src.Width, hgt = src.Height; + int sxMax, syMax; + float drawnW, drawnH; + + if (src.CropBounds is (var cbX, var cbY, var cbW, var cbH)) + { + // WebSource: Fill-style (stretch to cover, no aspect preservation) using + // the actual crop bounds. The transparent margins of the full canvas are + // preserved so they reveal layers beneath; only the crop content fills + // the element rect — matching the preview's Stretch="Fill" on the crop. + scale = Math.Max(ew / cbW, eh / cbH); + drawnW = cbW * scale; + drawnH = cbH * scale; + ox = (ew - drawnW) / 2f; + oy = (eh - drawnH) / 2f; + srcCropX = cbX; + srcCropY = cbY; + w = cbW; + hgt = cbH; + } + else + { + var (uScale, uOx, uOy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height); + scale = uScale; + ox = uOx; + oy = uOy; + drawnW = src.Width * scale; + drawnH = src.Height * scale; + } + + sxMax = w - 1; + syMax = hgt - 1; + var invScale = 1f / scale; var radius = Math.Min(ew, eh) / 2f; var cx = ew / 2f; var cy = eh / 2f; @@ -433,16 +466,6 @@ public sealed class SceneCompositor var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh)); if (x0 > x1 || y0 > y1) return; - // Integer fixed-point sampling + blend (libyuv's shape): the float - // SampleBgra + BlendPixel pair cost ~8 Math.Round + ~8 divisions per - // pixel and dominated this path in takes 3–4. Row invariants hoisted, - // the y-range check moved out of the x loop, and the dst alpha is the - // same opaque-master invariant as the row helpers (every write = 255). - var w = src.Width; - var hgt = src.Height; - var sxMax = w - 1; - var syMax = hgt - 1; - var invScale = 1f / scale; var op = (int)(Math.Clamp(opacity, 0f, 1f) * 256); if (op <= 0) return; var s = src.BgraPixels; @@ -451,7 +474,7 @@ public sealed class SceneCompositor { var py = y - ey; if (py < oy || py > oy + drawnH) continue; - var syRaw = Math.Clamp((py - oy) * invScale, 0f, syMax); + var syRaw = Math.Clamp((py - oy) * invScale, 0f, syMax) + srcCropY; var syi = (int)syRaw; var sy1i = Math.Min(syi + 1, syMax); var fy8 = (int)((syRaw - syi) * 256); @@ -459,7 +482,7 @@ public sealed class SceneCompositor var di = (y * dstW + x0) * 4; for (var x = x0; x <= x1; x++, di += 4) { - var px = x - ex; // element space + var px = x - ex; if (px < ox || px > ox + drawnW) continue; if (isRound) { @@ -468,7 +491,7 @@ public sealed class SceneCompositor if (dx * dx + dy * dy > radius * radius) continue; } - var sxRaw = (px - ox) * invScale; + var sxRaw = Math.Clamp((px - ox) * invScale, 0f, sxMax) + srcCropX; if (isMirror) sxRaw = sxMax - sxRaw; sxRaw = Math.Clamp(sxRaw, 0f, sxMax); var sxi = (int)sxRaw; diff --git a/Services/VideoFrame.cs b/Services/VideoFrame.cs index 05ac1bb..11ce551 100644 --- a/Services/VideoFrame.cs +++ b/Services/VideoFrame.cs @@ -27,6 +27,13 @@ public sealed class VideoFrame /// be transparent (static art, web overlays, chat, the social-bar strip). public bool IsOpaque { get; init; } + /// WebSource crop metadata: the alpha bounding box of the widget content + /// within the full canvas (X/Y/W/H in pixels). When set, the compositor uses these + /// bounds for Fill-style scaling (stretch to cover, no aspect preservation) instead + /// of UniformToFill. The transparent margins of the full canvas are preserved so + /// they reveal layers beneath — the crop defines the actual content region. + public (int X, int Y, int W, int H)? CropBounds { get; init; } + public VideoFrame(int width, int height, byte[] bgraPixels) { Width = width; diff --git a/Services/WebView2Manager.cs b/Services/WebView2Manager.cs index df1569c..9af04ad 100644 --- a/Services/WebView2Manager.cs +++ b/Services/WebView2Manager.cs @@ -270,7 +270,11 @@ public sealed class WebView2Manager : IDisposable session.PreviewBitmap = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null); session.PreviewBitmap.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0); - session.LatestFrame = new VideoFrame(pixW, pixH, pixels) { Epoch = ++session.Epoch }; + session.LatestFrame = new VideoFrame(pixW, pixH, pixels) + { + Epoch = ++session.Epoch, + CropBounds = (cropX, cropY, cropW, cropH), + }; // The preview bitmap is now MUTABLE and reused (CameraManager precedent): // WPF re-renders it after WritePixels; handlers must not Freeze it.