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
+49
View File
@@ -254,6 +254,55 @@ public class SceneCompositorTests
Assert.Equal(out2.BgraPixels, out3.BgraPixels);
}
/// <summary>THE page-transparency fix (the web widget's recording "black box"): a
/// semi-transparent layer flowing through the paste-cache raster must reveal the
/// backdrop through it (true source-over), never an opaque darkened block. The raster
/// is built on a TRANSPARENT base, but the old partial-alpha branch applied the
/// opaque-dst blend (premultiplied color + forced alpha 255), so every translucent
/// widget pixel pasted as opaque ink — the box in recordings while the raw-bitmap
/// preview stayed correct. A half-alpha blue widget over a red backdrop must read
/// (127,0,128) here; the buggy raster pastes (0,0,128).</summary>
[Fact]
public void PasteCache_SemiTransparentLayer_RevealsBackdrop_NotOpaqueInk()
{
var red = Solid(128, 64, 255, 0, 0); // live backdrop
var web = new byte[32 * 16 * 4]; // 2:1 widget canvas, half-alpha blue, uniform
for (var i = 0; i < web.Length; i += 4)
{
web[i] = 255; web[i + 1] = 0; web[i + 2] = 0; web[i + 3] = 128;
}
var semitransparent = new VideoFrame(32, 16, web); // NOT IsOpaque
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
var widget = new Source { Type = SourceType.WebSource, X = 8, Y = 8, Width = 16, Height = 16 };
var scene = new Scene { Name = "Live" };
scene.Elements.Add(background);
scene.Elements.Add(widget);
VideoFrame? FrameFor(SceneElement e) => e switch
{
Source { IsBackground: true } => red,
Source { Type: SourceType.WebSource } => semitransparent,
_ => null,
};
var options = new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 128, SourceRectHeight = 64,
OutputWidth = 128, OutputHeight = 64,
};
var compositor = new SceneCompositor();
var build = compositor.Render(scene, FrameFor, null, options); // raster build + paste
var hit = compositor.Render(scene, FrameFor, null, options); // paste-cache hit
foreach (var output in new[] { build, hit })
{
AssertColor(output, 16, 16, 127, 0, 128); // backdrop shows through the 50% blue
AssertColor(output, 4, 4, 255, 0, 0); // outside the element: untouched backdrop
}
Assert.Equal(build.BgraPixels, hit.BgraPixels);
}
/// <summary>take-16 regression gate — GC churn convicted mechanically: a ring-style
/// producer hands out a small set of rotating arrays with a NEW EPOCH every frame
/// (exactly what the capture/camera rings do). The compositor must re-rasterize INTO