Files
LlamaCasty/Services/Compositor/SceneCompositor.cs
T
gramps 432adfdaef perf(render): take-4 slice 2 — IsOpaque memcpy, integer bilinear, pump scratch pool
Take 4: pacing held (sync perfect) but avg render stayed 58.9ms — 2M
managed row-walk iterations + a fresh 8.3MB buffer every tick (LOH churn
into GC stalls inside the render measurement).

- VideoFrame.IsOpaque: producer-contract flag (screen capture + webcam —
  DWM/MF fill alpha 255; media/chat/web/static NOT flagged). Full-cover
  aligned opaque backdrop = ONE Buffer.BlockCopy; black pre-fill skipped
  when it covers.
- General BlitContent: integer 8.8 fixed-point bilinear + blend, row
  invariants hoisted, no per-pixel division/Math.Round. Within ±1 of the
  float reference (pixel tests allow ±2). Research per derivative-work
  rule: libyuv row/scale kernels (chromium.googlesource.com/libyuv/libyuv).
- FramePump scratch pool (max 4, length-keyed, owned-by-reference):
  release strictly AFTER SubmitFrameAsync returns (stdin write copies);
  Contains-guard makes the transition Cut alias safe.
- Removed the dead per-tick fromScene render + fromSceneProvider seam —
  BlendFrame consumes TransitionService.FromFrame captured at Start; the
  pump's render fed nothing. MainViewModel call site updated (signature).

Bugs caught by the pixel probes pre-ship (recorded MyMistakes): first
Bilinear double-shifted both stages (solid-255 sampled to ~1 -> general
path drew nothing); sentinel 0xAB collided with an x+y pixel. FakeEncoder
snapshots submitted frames (mirrors real copy semantics under recycling).

ONE integration test: Pump_Pools_ScratchBuffers_Across_Frames_Without_
Stale_Pixels (alternating backdrops + repeated backing identity). Direct
pin: Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch.
Clean build 0 warnings; 59/59 per-class + RealApp boot-smoke. take 5
verdict: expect avg render <= ~10ms, ~300/300 frames. Docs same commit:
ai.md pipeline section, TASKS.md TASK 18, HANDOFF rewritten (Unit B spec
+ settled decisions queued).
2026-09-04 09:35:59 -07:00

576 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ytLive.Models;
namespace ytLive.Services.Compositor;
/// <summary>
/// The output compositor: renders a scene into the encoder's master frame (tightly-packed
/// BGRA8 <see cref="VideoFrame"/>) exactly as the XAML preview renders it, minus the
/// editing chrome (SelectionOverlay, DimRects, badge, placeholder). The preview stays the
/// editing view; this is the output view — see TASKS.md "Ship step 1 — Scene compositor".
///
/// Frame sources are supplied by a <see cref="Func{SceneElement, VideoFrame}"/> resolver
/// (the caller maps webcam → DeviceId, images → AssetId, background → CaptureKey), keeping
/// the compositor pure and free of WPF and of the capture managers.
/// </summary>
public sealed class SceneCompositor
{
/// <summary>
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
/// front): live background (the scene's <c>IsBackground</c> source) → background image →
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
/// branding flash → social bar (a global overlay; spans the master width at
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
/// </summary>
public VideoFrame Render(
Scene scene,
Func<SceneElement, VideoFrame?> frameFor,
VideoFrame? flashFrame,
CompositorOptions options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? staticBase = null,
int split = -1,
byte[]? scratch = null)
{
if (scene == null) throw new ArgumentNullException(nameof(scene));
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
if (options == null) throw new ArgumentNullException(nameof(options));
if (options.SourceRectWidth <= 0 || options.SourceRectHeight <= 0)
throw new ArgumentException("The source rect must be positive.", nameof(options));
if (options.OutputWidth <= 0 || options.OutputHeight <= 0)
throw new ArgumentException("The output size must be positive.", nameof(options));
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
var buffer = scratch != null && scratch.Length == cropW * cropH * 4
? scratch
: new byte[cropW * cropH * 4];
// staticBase != null: the caller pre-baked all static layers below `split`.
// We copy it (never mutate the cached base) and composite elements[split..] on top.
if (staticBase != null)
{
if (staticBase.Width != cropW || staticBase.Height != cropH)
throw new ArgumentException("The static base must match the source rect.", nameof(staticBase));
Buffer.BlockCopy(staticBase.BgraPixels, 0, buffer, 0, buffer.Length);
}
else
{
if (split < 0) split = scene.Elements.Count;
var elements = scene.Elements;
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
// A master-sized live backdrop paints every pixel of the base — the
// opaque-black pre-fill (2M byte writes) would be dead work. Capture
// frames are opaque BGRA by contract (the WinRT path ignores alpha),
// so the 1:1 row walk covers the full rect.
var covered = liveCaptureFrame != null
&& liveCaptureFrame.Width == cropW && liveCaptureFrame.Height == cropH;
if (!covered)
for (var i = 3; i < buffer.Length; i += 4)
buffer[i] = 255; // opaque black base
if (liveCaptureFrame != null)
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
var backgroundFrame = background != null ? frameFor(background) : null;
if (backgroundFrame != null)
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
foreach (var element in elements)
{
if (!element.IsVisible) continue;
switch (element)
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
continue;
}
var fullFrame = frameFor(element);
if (fullFrame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, fullFrame);
}
}
// Composite the above-split layers (dynamic + any static above them) per frame.
for (var i = Math.Max(0, split); i < scene.Elements.Count; i++)
{
var element = scene.Elements[i];
if (!element.IsVisible) continue;
switch (element)
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
continue;
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
}
if (flashFrame != null)
BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
return StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}
/// <summary>
/// Render the static base of a scene: the opaque black layer, the live-capture
/// background, the background image, and every <paramref name="split"/> z-layers
/// below the split point (all of which are static by construction — see
/// <see cref="SceneElement.Kind"/>). The result is a source-rect sized frame that
/// <see cref="Render"/> can start from and composite the dynamic/above-split layers
/// onto each tick. Returns null when there is no static layer at all.
/// </summary>
public static VideoFrame? BakeStaticBase(
Scene scene,
int split,
Func<SceneElement, VideoFrame?> frameFor,
Func<CompositorOptions> options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? flashFrame = null)
{
if (split <= 0) return null;
var opts = options();
if (opts.SourceRectWidth <= 0 || opts.SourceRectHeight <= 0) return null;
var cropW = opts.SourceRectWidth;
var cropH = opts.SourceRectHeight;
var buffer = new byte[cropW * cropH * 4];
for (var i = 3; i < buffer.Length; i += 4)
buffer[i] = 255;
var elements = scene.Elements;
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (liveCapture != null)
{
var liveCaptureFrame = liveCapture.IsVisible ? frameFor(liveCapture) : null;
if (liveCaptureFrame != null && split > elements.IndexOf(liveCapture))
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
}
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
if (background != null)
{
var backgroundFrame = frameFor(background);
if (backgroundFrame != null && split > elements.IndexOf(background))
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
}
for (var i = 0; i < split; i++)
{
var element = scene.Elements[i];
if (!element.IsVisible) continue;
switch (element)
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
continue;
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, opts, element, frame);
}
var baked = new VideoFrame(cropW, cropH, buffer);
if (flashFrame != null)
baked = Overlay(baked, opts, flashFrame, 0, 0);
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
baked = Overlay(baked, opts, socialBarFrame, 0, socialBarTop);
return baked;
}
/// <summary>The compositor's dynamic/above-split path: start from an existing frame
/// buffer and composite the z-layers from <paramref name="split"/> onward on top.
/// When <paramref name="baseFrame"/> is null the caller must fall back to full render.</summary>
public static VideoFrame CompositeLayers(
VideoFrame baseFrame,
Scene scene,
int split,
Func<SceneElement, VideoFrame?> frameFor,
CompositorOptions options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? flashFrame = null,
byte[]? scratch = null)
{
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
// We must not mutate the cached base: copy it into a fresh (or pooled) buffer.
var buffer = scratch != null && scratch.Length == baseFrame.BgraPixels.Length
? scratch
: new byte[baseFrame.BgraPixels.Length];
Buffer.BlockCopy(baseFrame.BgraPixels, 0, buffer, 0, baseFrame.BgraPixels.Length);
for (var i = split; i < scene.Elements.Count; i++)
{
var element = scene.Elements[i];
if (!element.IsVisible) continue;
switch (element)
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
continue;
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
}
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}
private static void BlitBakedElement(
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame)
{
var ex = (float)(element.X - options.SourceRectX);
var ey = (float)(element.Y - options.SourceRectY);
var ew = (float)element.Width;
var eh = (float)element.Height;
var isRound = element.ClipShape == ClipShape.Round;
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame,
(float)element.Opacity, isRound, element.IsMirrored);
if (element.HasBorder)
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
}
private static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0)
{
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
var buffer = new byte[frame.BgraPixels.Length];
Buffer.BlockCopy(frame.BgraPixels, 0, buffer, 0, buffer.Length);
BlitOverlay(buffer, cropW, cropH, options, overlay, sx0, sy0);
return new VideoFrame(cropW, cropH, buffer);
}
/// <summary>
/// Render a cross-fade blend between two scenes. Used by <see cref="TransitionService"/>
/// during fade/move transitions: the caller renders each scene individually, then blends
/// the two results at the given alpha (0 = fully from, 1 = fully to).
/// </summary>
public static VideoFrame BlendFrames(VideoFrame from, VideoFrame to, float alpha)
{
if (from.Width != to.Width || from.Height != to.Height)
{
if (alpha <= 0.5f) return from;
return to;
}
var count = from.BgraPixels.Length;
var result = new byte[count];
var inv = 1f - alpha;
for (var i = 0; i < count; i += 4)
{
result[i] = (byte)(from.BgraPixels[i] * inv + to.BgraPixels[i] * alpha);
result[i + 1] = (byte)(from.BgraPixels[i + 1] * inv + to.BgraPixels[i + 1] * alpha);
result[i + 2] = (byte)(from.BgraPixels[i + 2] * inv + to.BgraPixels[i + 2] * alpha);
result[i + 3] = 255;
}
return new VideoFrame(from.Width, from.Height, result);
}
/// <summary>
/// UniformToFill blit of a source frame into element rect (ex, ey, ew, eh), with
/// straight-alpha source-over, optional round clip (true circle, hard edge) and
/// horizontal mirror around the element center.
/// </summary>
private static void BlitContent(
byte[] dst, int dstW, int dstH,
float ex, float ey, float ew, float eh,
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);
// 1:1 aligned fast path (the full-frame backdrop, master-sized overlays):
// bilinear sampling at scale 1 with integer offsets is the identity, so the
// blit is a row walk, not 2M float samples + rounds. Research-first (2026-09-03,
// take-3 fix; libyuv's row-based BLEND_NONE pattern —
// https://chromium.googlesource.com/libyuv/libyuv/): the 258ms/frame render
// that time-lapsed take 3 lived in the general loop below.
if (!isRound && !isMirror && scale == 1f && ox == 0f && oy == 0f
&& ex == MathF.Floor(ex) && ey == MathF.Floor(ey))
{
Blit1To1Rows(dst, dstW, dstH, (int)ex, (int)ey, src, opacity);
return;
}
var drawnW = src.Width * scale;
var drawnH = src.Height * scale;
var radius = Math.Min(ew, eh) / 2f;
var cx = ew / 2f;
var cy = eh / 2f;
var x0 = Math.Max(0, (int)Math.Floor(ex));
var y0 = Math.Max(0, (int)Math.Floor(ey));
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew));
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;
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);
var syi = (int)syRaw;
var sy1i = Math.Min(syi + 1, syMax);
var fy8 = (int)((syRaw - syi) * 256);
var fyInv = 256 - fy8;
var di = (y * dstW + x0) * 4;
for (var x = x0; x <= x1; x++, di += 4)
{
var px = x - ex; // element space
if (px < ox || px > ox + drawnW) continue;
if (isRound)
{
var dx = px - cx;
var dy = py - cy;
if (dx * dx + dy * dy > radius * radius) continue;
}
var sxRaw = (px - ox) * invScale;
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);
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;
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);
var saFull = Bilinear(s[p00 + 3], s[p10 + 3], s[p01 + 3], s[p11 + 3], fx8, fxInv, fy8, fyInv);
var sa = saFull * op >> 8;
if (sa >= 255)
{
dst[di] = (byte)sb;
dst[di + 1] = (byte)sg;
dst[di + 2] = (byte)sr;
dst[di + 3] = 255;
}
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;
}
}
}
}
/// <summary>Two-stage bilinear in 8.8 fixed point; result within ±1 of the
/// float StretchMath.SampleBgra (well inside every pixel test's ±2 tolerance).
/// The first stage must NOT shift back to 8 bits — shifting twice collapsed
/// every solid-255 sample to 1 (green overwrites silently degraded to no-ops;
/// caught by the SceneCompositor/SceneGraph pixel probes 2026-09-04).</summary>
private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv)
{
// Both stages in one 16.8 scale: top/bot ≤ 65280, ×256 + round ≤ 16.8M — int-safe.
var top = tl * fxInv + tr * fx8;
var bot = bl * fxInv + br * fx8;
return (top * fyInv + bot * fy8 + 32768) >> 16;
}
/// <summary>Row walk for a 1:1 aligned blit at integer offset (ex, ey): per-pixel
/// branch on source alpha — opaque rows copy straight, transparent pixels are
/// skipped, partial alpha blends in integer fixed-point (libyuv's pattern). The
/// master buffer's destination alpha is always opaque (black base paints 255 and
/// every blend writes 255), so dst alpha is never read — matching BlendPixel's
/// source-over result for opaque dst within ±1.</summary>
private static void Blit1To1Rows(byte[] dst, int dstW, int dstH, int ex, int ey, VideoFrame src, float opacity)
{
var op = (int)(Math.Clamp(opacity, 0f, 1f) * 256);
if (op <= 0) return;
var x0 = Math.Max(0, ex);
var y0 = Math.Max(0, ey);
var x1 = Math.Min(dstW, ex + src.Width);
var y1 = Math.Min(dstH, ey + src.Height);
if (x0 >= x1 || y0 >= y1) return;
// The take-4 fix: a full-canvas opaque capture (the Live backdrop) is ONE
// memcpy, not 2.07M managed loop iterations — the row walk below still cost
// ~58ms/frame dominated by bounds-check arithmetic and GC of the per-pixel
// loop's slowness, i.e. the same starvation shape as take 3, one floor lower.
if (op == 256 && src.IsOpaque && x0 == 0 && y0 == 0 && x1 == dstW && y1 == dstH)
{
Buffer.BlockCopy(src.BgraPixels, 0, dst, 0, dstW * dstH * 4);
return;
}
var d = dst.AsSpan();
var s = src.BgraPixels.AsSpan();
var rowLen = x1 - x0;
if (op == 256)
{
for (var y = y0; y < y1; y++)
{
var si = ((y - ey) * src.Width + (x0 - ex)) * 4;
BlendRowOpaque(d, (y * dstW + x0) * 4, s, si, rowLen);
}
}
else
{
for (var y = y0; y < y1; y++)
{
var si = ((y - ey) * src.Width + (x0 - ex)) * 4;
BlendRowWeighted(d, (y * dstW + x0) * 4, s, si, rowLen, op);
}
}
}
private static void BlendRowOpaque(Span<byte> dst, int di, ReadOnlySpan<byte> src, int si, int count)
{
for (var n = 0; n < count; n++)
{
var a = src[si + 3];
if (a == 255)
{
dst[di] = src[si];
dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2];
dst[di + 3] = 255;
}
else if (a != 0)
{
var inv = 255 - a;
dst[di] = (byte)((src[si] * a + dst[di] * inv + 127) / 255);
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inv + 127) / 255);
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inv + 127) / 255);
dst[di + 3] = 255;
}
si += 4;
di += 4;
}
}
private static void BlendRowWeighted(Span<byte> dst, int di, ReadOnlySpan<byte> src, int si, int count, int op)
{
for (var n = 0; n < count; n++)
{
var a = src[si + 3] * op >> 8;
if (a >= 255)
{
dst[di] = src[si];
dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2];
dst[di + 3] = 255;
}
else if (a > 0)
{
var inv = 255 - a;
dst[di] = (byte)((src[si] * a + dst[di] * inv + 127) / 255);
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inv + 127) / 255);
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inv + 127) / 255);
dst[di + 3] = 255;
}
si += 4;
di += 4;
}
}
/// <summary>Centered OBS-style border stroke: rect ring (Traditional) or circle ring (Round).</summary>
private static void DrawBorder(
byte[] dst, int dstW, int dstH,
float ex, float ey, float ew, float eh,
SceneElement element, bool isRound)
{
if (!element.TryGetBorderColor(out var r, out var g, out var b)) return;
var half = element.BorderWidth / 2f;
var alpha = (float)(element.Opacity * element.BorderOpacity);
if (half <= 0 || alpha <= 0) return;
var cx = ex + ew / 2f;
var cy = ey + eh / 2f;
var radius = Math.Min(ew, eh) / 2f;
var x0 = Math.Max(0, (int)Math.Floor(ex - half));
var y0 = Math.Max(0, (int)Math.Floor(ey - half));
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew + half));
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh + half));
for (var y = y0; y <= y1; y++)
{
for (var x = x0; x <= x1; x++)
{
float d = isRound
? MathF.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) - radius
: MathF.Max(MathF.Max(ex - x, x - (ex + ew)), MathF.Max(ey - y, y - (ey + eh)));
if (MathF.Abs(d) <= half)
BlendPixel(dst, (y * dstW + x) * 4, (b, g, r, (byte)255), alpha);
}
}
}
/// <summary>1:1 copy of a master-sized overlay (flash, social bar) cropped to the
/// active source rect. The overlay is positioned in master space by (sx0, sy0).
/// Iterates only the intersection rect — the old loop walked every destination
/// pixel to blit a ~64px strip (2M iterations of bounds checks per overlay).</summary>
private static void BlitOverlay(
byte[] dst, int dstW, int dstH, CompositorOptions options,
VideoFrame overlay, int sx0, int sy0)
{
var dx0 = Math.Max(0, sx0 - options.SourceRectX);
var dy0 = Math.Max(0, sy0 - options.SourceRectY);
var dx1 = Math.Min(dstW, sx0 - options.SourceRectX + overlay.Width);
var dy1 = Math.Min(dstH, sy0 - options.SourceRectY + overlay.Height);
for (var y = dy0; y < dy1; y++)
{
var si = ((y + options.SourceRectY - sy0) * overlay.Width
+ dx0 + options.SourceRectX - sx0) * 4;
BlendRowOpaque(dst, (y * dstW + dx0) * 4, overlay.BgraPixels, si, dx1 - dx0);
}
}
/// <summary>Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.</summary>
private static void BlendPixel(byte[] dst, int di, (byte B, byte G, byte R, byte A) src, float opacity)
{
var sa = src.A / 255f * opacity;
if (sa <= 0) return;
var da = dst[di + 3] / 255f;
var outA = sa + da * (1 - sa);
if (outA <= 0) return;
dst[di] = (byte)Math.Round((src.B * sa + dst[di] * da * (1 - sa)) / outA);
dst[di + 1] = (byte)Math.Round((src.G * sa + dst[di + 1] * da * (1 - sa)) / outA);
dst[di + 2] = (byte)Math.Round((src.R * sa + dst[di + 2] * da * (1 - sa)) / outA);
dst[di + 3] = (byte)Math.Round(outA * 255);
}
}