From f6802c712401fd1a83e9fc61aa71f89fbfcee836 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 8 Sep 2026 12:07:54 -0700 Subject: [PATCH] fix(web): stride-correct CropBounds Fill rendering in compositor (take-23) --- Services/Compositor/SceneCompositor.cs | 73 +++++++++++++++----------- Services/VideoFrame.cs | 6 +-- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs index 8b12fdf..711ec53 100644 --- a/Services/Compositor/SceneCompositor.cs +++ b/Services/Compositor/SceneCompositor.cs @@ -421,41 +421,37 @@ public sealed class SceneCompositor { if (ew <= 0 || eh <= 0) return; - float scale, ox, oy; - int srcCropX = 0, srcCropY = 0; - int w = src.Width, hgt = src.Height; - int sxMax, syMax; + float scale, ox, oy, invScale; float drawnW, drawnH; + var cbX = 0; + var cbY = 0; + var cbW = 0; + var cbH = 0; - if (src.CropBounds is (var cbX, var cbY, var cbW, var cbH)) + if (src.CropBounds is { } cb) { - // 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. + cbX = cb.X; + cbY = cb.Y; + cbW = cb.W; + cbH = cb.H; scale = Math.Max(ew / cbW, eh / cbH); + invScale = 1f / scale; 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; + invScale = 1f / uScale; ox = uOx; oy = uOy; - drawnW = src.Width * scale; - drawnH = src.Height * scale; + drawnW = src.Width * uScale; + drawnH = src.Height * uScale; } - 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; @@ -469,16 +465,24 @@ public sealed class SceneCompositor var op = (int)(Math.Clamp(opacity, 0f, 1f) * 256); if (op <= 0) return; var s = src.BgraPixels; + var srcStride = src.Width; // canvas stride in pixels (not bytes) for (var y = y0; y <= y1; y++) { var py = y - ey; if (py < oy || py > oy + drawnH) continue; - 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); + + // syNorm: normalized [0,1] position within the drawn content (vertically) + var syNorm = Math.Clamp((py - oy) / drawnH, 0f, 1f); + // syCrop: pixel position within the crop region + var syCrop = syNorm * cbH; + // syCanvas: canvas pixel row (for buffer indexing) + var syCanvas = Math.Clamp(cbY + syCrop, 0f, src.Height - 1f); + var syi = (int)syCanvas; + var sy1i = Math.Min(syi + 1, src.Height - 1); + var fy8 = (int)((syCanvas - syi) * 256); var fyInv = 256 - fy8; + var di = (y * dstW + x0) * 4; for (var x = x0; x <= x1; x++, di += 4) { @@ -491,18 +495,23 @@ public sealed class SceneCompositor if (dx * dx + dy * dy > radius * radius) continue; } - 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; - var sx1i = Math.Min(sxi + 1, sxMax); - var fx8 = (int)((sxRaw - sxi) * 256); + // sxNorm: normalized [0,1] position within the drawn content (horizontally) + var sxNorm = Math.Clamp((px - ox) / drawnW, 0f, 1f); + // sxCrop: pixel position within the crop region + var sxCrop = sxNorm * cbW; + // sxCanvas: canvas pixel column (for buffer indexing) + var sxCanvas = Math.Clamp(cbX + sxCrop, 0f, src.Width - 1f); + if (isMirror) sxCanvas = src.Width - 1 - sxCanvas; + var sxi = (int)sxCanvas; + var sx1i = Math.Min(sxi + 1, src.Width - 1); + var fx8 = (int)((sxCanvas - sxi) * 256); var fxInv = 256 - fx8; - var p00 = (syi * w + sxi) * 4; - var p10 = (syi * w + sx1i) * 4; - var p01 = (sy1i * w + sxi) * 4; - var p11 = (sy1i * w + sx1i) * 4; + // ALL pixel indexing uses srcStride (canvas stride), NOT crop width + var p00 = (syi * srcStride + sxi) * 4; + var p10 = (syi * srcStride + sx1i) * 4; + var p01 = (sy1i * srcStride + sxi) * 4; + var p11 = (sy1i * srcStride + sx1i) * 4; var sb = Bilinear(s[p00], s[p10], s[p01], s[p11], fx8, fxInv, fy8, fyInv); var sg = Bilinear(s[p00 + 1], s[p10 + 1], s[p01 + 1], s[p11 + 1], fx8, fxInv, fy8, fyInv); var sr = Bilinear(s[p00 + 2], s[p10 + 2], s[p01 + 2], s[p11 + 2], fx8, fxInv, fy8, fyInv); diff --git a/Services/VideoFrame.cs b/Services/VideoFrame.cs index 11ce551..3b5f328 100644 --- a/Services/VideoFrame.cs +++ b/Services/VideoFrame.cs @@ -29,9 +29,9 @@ public sealed class VideoFrame /// 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. + /// 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)