fix(compositor): keep straight alpha in the paste-cache raster — the web widget's recording black box

The element-space raster builds on a TRANSPARENT base but BlitContentRaw's
partial-alpha branch applied the opaque-dst source-over blend: color premultiplied
by the sampled alpha, then alpha forced to 255. Pasting that raster saw a==255 and
straight-copied darkened ink over the scene — a recording box that the raw-bitmap
preview (correct alpha) never showed. Transparent margins and opaque content were
unaffected, which is why every take looped on the page/CSS while the capture was
transparent all along (15:51 dumps: alpha max 255, mean ~19, zero 57%).

BlitContentRaw now takes transparentDst; the raster call passes true and writes
straight color + straight alpha so the paste rows (BlendRowOpaque/Weighted) do the
real source-over onto the opaque master. Master paths byte-identical.

ONE integration test PasteCache_SemiTransparentLayer_RevealsBackdrop_NotOpaqueInk:
50%-blue over red reads (127,0,128) fixed vs (0,0,128) buggy — proven both ways
(verified by stashing the fix: fails before, passes after). Clean build, 0 warnings;
22/23 compositor-class tests pass, the sole failure the documented pre-existing
Composite_FullScene_MasterPixels pixel (1380,700).

Alpha-compositing model: standard source-over with producer-cached surfaces, the
OBS/libyuv paste model already cited in ai.md/MyMistakes (rawvideo recipe, row-blit
BLEND_NONE / straight-alpha branches); full story in MyMistakes (RESOLVED entry).

Per the good-dog rule: one integration test, memory updates (MyMistakes/ai.md/
HANDOFF) in the same commit.
This commit is contained in:
2026-09-12 08:57:58 -07:00
parent 0f72c53aa5
commit efe88b8a0b
5 changed files with 162 additions and 43 deletions
+24 -7
View File
@@ -308,7 +308,7 @@ public sealed class SceneCompositor
}
if (raster == null) raster = new VideoFrame(dw, dh, new byte[dw * dh * 4]);
Array.Clear(raster.BgraPixels);
BlitContentRaw(raster.BgraPixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror);
BlitContentRaw(raster.BgraPixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror, transparentDst: true);
lock (_pasteGate)
{
// Only the SOURCE frame's identity is a real key owner; drop any older
@@ -419,7 +419,8 @@ public sealed class SceneCompositor
private static void BlitContentRaw(
byte[] dst, int dstW, int dstH,
float ex, float ey, float ew, float eh,
VideoFrame src, float opacity, bool isRound, bool isMirror)
VideoFrame src, float opacity, bool isRound, bool isMirror,
bool transparentDst = false)
{
if (ew <= 0 || eh <= 0) return;
@@ -529,11 +530,27 @@ public sealed class SceneCompositor
}
else if (sa > 0)
{
var inv = 255 - sa;
dst[di] = (byte)((sb * sa + dst[di] * inv + 127) / 255);
dst[di + 1] = (byte)((sg * sa + dst[di + 1] * inv + 127) / 255);
dst[di + 2] = (byte)((sr * sa + dst[di + 2] * inv + 127) / 255);
dst[di + 3] = 255;
if (transparentDst)
{
// Element-raster pass on a TRANSPARENT base (the paste cache):
// keep straight color + straight alpha so the paste rows can
// source-over onto the opaque master. Premultiplying here (the
// opaque-dst blend) stamped alpha 255 onto already-alpha-scaled
// color — the paste over-wrote the scene with darkened ink, the
// web widget's "black box" in recordings.
dst[di] = (byte)sb;
dst[di + 1] = (byte)sg;
dst[di + 2] = (byte)sr;
dst[di + 3] = (byte)sa;
}
else
{
var inv = 255 - sa;
dst[di] = (byte)((sb * sa + dst[di] * inv + 127) / 255);
dst[di + 1] = (byte)((sg * sa + dst[di + 1] * inv + 127) / 255);
dst[di + 2] = (byte)((sr * sa + dst[di + 2] * inv + 127) / 255);
dst[di + 3] = 255;
}
}
}
}