Files
LlamaCasty/Services/Compositor/SceneCompositor.cs
T
gramps 71d0e87cac feat(blur): a privacy blur layer for the Live scene — boxes and circles over the desktop
Creator request 2026-11-01: a layer that hides part of the desktop from viewers. Box or circle,
drag/resize like any layer, one strength slider, several allowed.

**Z-order is policy, not preference.** `AddSource` inserts a blur directly above the background
and therefore below the brand logo, and `OnSceneElementsReordered` pushes any blur that ends up
above the brand back down. Nothing else in the app can move it into the brand's way.

**Overlap = the strongest single blur, never a stack.** `SceneCompositor.ApplyBlurLayers` collects
every visible blur the first time one is reached in the z-order, sorts them strongest-first, and
shares one coverage map: a pixel already covered by a stronger blur is skipped. Blurring
back-to-front would have produced something stronger than any slider setting allows, which is
exactly what was ruled out.

**One blur curve, shared with the webcam.** `RegionBlur` carries the constants the webcam blur
already used — downscale 4, radius 1–6 downscaled px, `RadiusFor(strength)` — and
`BackgroundEffectProcessor` now delegates to it instead of holding its own copy. The layer's
slider is the same 0–1 scale with the same 0.5 default, so "half strength" means the same blur in
both features. Cost is proportional to the blur's own rect, not the frame.

**Live scene only**, enforced in `AddSource` as well as the (+) menu (`CanAddBlur`), so a stale
menu item or a scripted call cannot plant one on BRB. `ElementKind.Dynamic`, so it is never baked
into the static base and every render path gets it — pump, preview, thumbnails, scene dump.

Persistence: `Source.BlurAmount` column + migration; shape rides the existing `ClipShape`.
Properties panel: Box | Circle plus the strength slider, gated on `IsBlurSelected`.

4 tests: outside the mask is byte-identical, inside loses its hard edges, a circle leaves its
box corners alone, and two identical stacked blurs equal one (the coverage map). Release builds
at 0 warnings.
2026-11-01 09:55:04 -08:00

993 lines
46 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 System.Buffers;
using System.Collections.Generic;
using ytLive.Helpers;
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
{
// Paste cache (take-8 fix, 2026-09-04): the Live scene re-sampled ~680k bilinear
// pixels EVERY tick even though only the camera actually changes at that rate —
// chat/web/image layers are identical frame after frame. Same shape as OBS (the
// source surface is cached, the compositor blits): a scaled/round-clipped/mirrored
// layer rasterizes once and is pasted (row-copy/blend) on every later tick.
// Keyed by the SOURCE FRAME IDENTITY (array + Epoch + CropBounds) — producers hand
// out fresh immutable byte[], or recycled-ring arrays whose monotonic Epoch distinguishes
// each generation (screen capture, take-11 fix). CropBounds must be in the key because
// the same frame (same Pixels/Epoch/Width/Height) can have different CropBounds as
// the element is resized — a stale cached raster with wrong crop pixels would corrupt
// the output. Bounds-checked eviction clears wholesale.
private sealed record PasteKey(byte[] Pixels, long Epoch, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror, (int X, int Y, int W, int H)? CropBounds);
private static readonly Dictionary<PasteKey, VideoFrame> _pasteCache = new();
private static readonly Dictionary<SceneElement, VideoFrame> _elementRasters = new(ReferenceEqualityComparer.Instance);
private static readonly object _pasteGate = new();
private const int MaxPasteEntries = 256;
/// <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"/>) → stream-alert ticker (drawn INSIDE the Stream
/// Alerts box, at the origin the frame carries on <see cref="VideoFrame.Placement"/>;
/// creator ruling 2026-09-26 — it used to be a global bar pinned to the very top).
/// 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,
VideoFrame? tickerFrame = null,
CompositeTimings? timings = 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));
var tBase = System.Diagnostics.Stopwatch.GetTimestamp();
Buffer.BlockCopy(staticBase.BgraPixels, 0, buffer, 0, buffer.Length);
timings?.Add("base", System.Diagnostics.Stopwatch.GetTimestamp() - tBase);
}
else
{
if (split < 0) split = scene.Elements.Count;
var tBackdrop = System.Diagnostics.Stopwatch.GetTimestamp();
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)
{
if (liveCaptureFrame != null)
{
var (uScale, uOx, uOy) = StretchMath.UniformToFill(cropW, cropH, liveCaptureFrame.Width, liveCaptureFrame.Height);
var drawnW = liveCaptureFrame.Width * uScale;
var drawnH = liveCaptureFrame.Height * uScale;
var ox = (int)Math.Floor(uOx);
var oy = (int)Math.Floor(uOy);
var oh = (int)Math.Ceiling(oy + drawnH);
var ow = cropW;
if (oy > 0)
{
var topCount = oy * ow;
for (var i = 3; i < topCount * 4; i += 4)
buffer[i] = 255;
}
if (oh < cropH)
{
var start = oh * ow;
var bottomCount = (cropH - oh) * ow;
for (var i = start * 4 + 3; i < (start + bottomCount) * 4; i += 4)
buffer[i] = 255;
}
if (ox > 0 || (int)Math.Ceiling(uOx + drawnW) < cropW)
{
var x0s = (int)Math.Floor(uOx);
var x1s = (int)Math.Ceiling(uOx + drawnW);
for (var y = oy; y < oh && y < cropH; y++)
{
var di = (y * ow + 0) * 4;
for (var x = 0; x < x0s && x < ow; x++)
{
buffer[di + x * 4 + 3] = 255;
}
for (var x = x1s; x < ow; x++)
{
buffer[di + x * 4 + 3] = 255;
}
}
}
}
else
{
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);
timings?.Add("backdrop", System.Diagnostics.Stopwatch.GetTimestamp() - tBackdrop);
for (var ei = 0; ei < elements.Count; ei++)
{
var element = elements[ei];
if (!element.IsVisible) continue;
switch (element)
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement { IsZoomActive: true }:
continue;
}
var fullFrame = frameFor(element);
if (fullFrame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, fullFrame, timings, $"#{ei} {element.Name}");
}
}
// Composite the above-split layers (dynamic + any static above them) per frame.
var blurApplied = false;
for (var i = Math.Max(0, split); i < scene.Elements.Count; i++)
{
var element = scene.Elements[i];
if (!element.IsVisible) continue;
// Privacy blurs are handled as ONE group the first time one is reached: overlapping
// boxes must resolve to the strongest single blur, never to a blurred blur. The group
// runs at the lowest blur's z-position, which is where the creator put it — directly
// above the background and below the brand logo.
if (element is Source { Type: SourceType.Blur })
{
if (!blurApplied)
{
blurApplied = true;
ApplyBlurLayers(scene, buffer, cropW, cropH, options, timings);
}
continue;
}
switch (element)
{
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement { IsZoomActive: true }:
continue;
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, frame, timings, $"#{i} {element.Name}");
}
if (flashFrame != null)
{
var tFlash = System.Diagnostics.Stopwatch.GetTimestamp();
BlitOverlay(buffer, cropW, cropH, options, flashFrame, 0, 0);
timings?.Add("flash", System.Diagnostics.Stopwatch.GetTimestamp() - tFlash);
}
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
{
var tSocial = System.Diagnostics.Stopwatch.GetTimestamp();
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
timings?.Add("social", System.Diagnostics.Stopwatch.GetTimestamp() - tSocial);
}
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
{
var tTicker = System.Diagnostics.Stopwatch.GetTimestamp();
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
timings?.Add("ticker", System.Diagnostics.Stopwatch.GetTimestamp() - tTicker);
}
// The zoom box is layer n+1: a live 2x view of a FIXED capture window sampled
// from the frame just composited (all layers). Drawn LAST — above everything.
// The box/2 capture is anchored at the click, so dragging the box only moves
// the box; the content it shows never changes (creator ruling 2026-10-08).
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
if (zoom != null)
{
var tZoom = System.Diagnostics.Stopwatch.GetTimestamp();
BlitZoom(buffer, cropW, cropH, options, zoom);
timings?.Add("zoom", System.Diagnostics.Stopwatch.GetTimestamp() - tZoom);
}
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
var scaled = StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
return scaled;
}
/// <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,
CompositeTimings? timings = null)
{
if (split <= 0) return null;
var opts = options();
if (opts.SourceRectWidth <= 0 || opts.SourceRectHeight <= 0) return null;
var tBake = System.Diagnostics.Stopwatch.GetTimestamp();
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);
}
timings?.Add("bake backdrop", System.Diagnostics.Stopwatch.GetTimestamp() - tBake);
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 }:
case ZoomSceneElement:
continue;
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, opts, element, frame, timings, $"bake #{i} {element.Name}");
}
var baked = new VideoFrame(cropW, cropH, buffer);
if (flashFrame != null || (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0))
{
var tOverlays = System.Diagnostics.Stopwatch.GetTimestamp();
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);
timings?.Add("bake overlays", System.Diagnostics.Stopwatch.GetTimestamp() - tOverlays);
}
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.
/// The social bar never reaches here (it IS baked into the static base) but the alert
/// ticker must: it is dynamic by construction — only ever present while an alert plays —
/// so it overlays LAST, pinned to the very top (TASK 47).</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,
VideoFrame? tickerFrame = null,
CompositeTimings? timings = 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];
var tBase = System.Diagnostics.Stopwatch.GetTimestamp();
Buffer.BlockCopy(baseFrame.BgraPixels, 0, buffer, 0, baseFrame.BgraPixels.Length);
timings?.Add("base", System.Diagnostics.Stopwatch.GetTimestamp() - tBase);
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 }:
case ZoomSceneElement { IsZoomActive: true }:
continue;
}
var frame = frameFor(element);
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, frame, timings, $"#{i} {element.Name}");
}
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
{
var tTicker = System.Diagnostics.Stopwatch.GetTimestamp();
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
timings?.Add("ticker", System.Diagnostics.Stopwatch.GetTimestamp() - tTicker);
}
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
if (zoom != null)
{
var tZoom = System.Diagnostics.Stopwatch.GetTimestamp();
BlitZoom(buffer, cropW, cropH, options, zoom);
timings?.Add("zoom", System.Diagnostics.Stopwatch.GetTimestamp() - tZoom);
}
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
var scaled = StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
return scaled;
}
/// <summary>
/// Apply every visible privacy blur to the composed-so-far buffer, strongest first, skipping
/// pixels a stronger blur already covered. That ordering is what makes an overlap read as "the
/// strongest one wins": blurring back-to-front would stack passes and produce something
/// stronger than any single slider allows, which the creator ruled out.
/// </summary>
private static void ApplyBlurLayers(
Scene scene, byte[] buffer, int cropW, int cropH, CompositorOptions options, CompositeTimings? timings)
{
var blurs = scene.Elements.OfType<Source>()
.Where(s => s.Type == SourceType.Blur && s.IsVisible && s.Width > 0 && s.Height > 0)
.OrderByDescending(s => s.BlurAmount)
.ToArray();
if (blurs.Length == 0) return;
var tBlur = System.Diagnostics.Stopwatch.GetTimestamp();
var coverage = System.Buffers.ArrayPool<byte>.Shared.Rent(cropW * cropH);
try
{
Array.Clear(coverage, 0, cropW * cropH);
var stride = cropW * 4;
foreach (var blur in blurs)
{
var x = (int)Math.Round(blur.X - options.SourceRectX);
var y = (int)Math.Round(blur.Y - options.SourceRectY);
RegionBlur.ApplyInPlace(
buffer, stride, cropW, cropH,
x, y, (int)Math.Round(blur.Width), (int)Math.Round(blur.Height),
blur.BlurAmount, blur.ClipShape == ClipShape.Round, coverage);
}
}
finally
{
System.Buffers.ArrayPool<byte>.Shared.Return(coverage);
}
timings?.Add("blur", System.Diagnostics.Stopwatch.GetTimestamp() - tBlur);
}
private static void BlitBakedElement(
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame,
CompositeTimings? timings, string stage)
{
var tElement = System.Diagnostics.Stopwatch.GetTimestamp();
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;
// All rect-placed elements route through the paste cache (take-9 lesson): the
// IsOpaque bypass kept re-sampling the webcam (~156k px) EVERY tick even when
// the device frame had not changed; an opaque source rasterizes to an opaque
// raster whose paste is a per-pixel copy branch, so hits beat the sampler and
// misses cost exactly what the bypass cost.
BlitCachedLayer(buffer, cropW, cropH, ex, ey, ew, eh, frame, element, isRound, timings, stage);
if (element.HasBorder)
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
timings?.Add(stage, System.Diagnostics.Stopwatch.GetTimestamp() - tElement);
}
/// <summary>Non-opaque layer blit through the paste cache: the element-space raster
/// (scaled to the element rect, round-clipped, mirrored) depends only on the source
/// frame + rect + shape/mirror — NOT on position or opacity. Static content (chat,
/// web overlays, images, the media frame between updates) is sampled ONCE and pasted
/// at integer position with per-tixel blend; only layers whose source frame actually
/// changed (webcam at device fps) pay resampling.</summary>
private static void BlitCachedLayer(
byte[] buffer, int cropW, int cropH,
float ex, float ey, float ew, float eh,
VideoFrame frame, SceneElement element, bool isRound,
CompositeTimings? timings, string stage)
{
if (ew <= 0 || eh <= 0) return;
var dw = (int)System.Math.Ceiling(ew);
var dh = (int)System.Math.Ceiling(eh);
if (dw <= 0 || dh <= 0) return;
var mirror = element.IsMirrored;
PasteKey? key = null;
VideoFrame? raster = null;
lock (_pasteGate)
{
key = new PasteKey(frame.BgraPixels, frame.Epoch, frame.Width, frame.Height, dw, dh, isRound, mirror, frame.CropBounds);
_pasteCache.TryGetValue(key, out raster);
}
if (raster == null)
{
var tRaster = System.Diagnostics.Stopwatch.GetTimestamp();
// Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255
// alpha only where it actually draws, so untouched margins (outside the
// round clip, past the UniformToFill edges) stay alpha 0 and the paste
// reveals the master beneath instead of black. The ARRAY is reused per
// element (see _elementRasters note) — new source generations re-raster
// in place, so the steady state allocates nothing.
lock (_pasteGate)
{
if (_elementRasters.TryGetValue(element, out var existing)
&& existing.Width == dw && existing.Height == dh)
raster = existing;
}
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, transparentDst: true);
lock (_pasteGate)
{
// Only the SOURCE frame's identity is a real key owner; drop any older
// entry that shared this element's reused raster so a stale key can never
// paste a mid-update array.
if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear();
_pasteCache[key!] = raster;
_elementRasters[element] = raster;
}
timings?.Add(stage + " raster", System.Diagnostics.Stopwatch.GetTimestamp() - tRaster);
}
// Paste at integer position; opacity blends. The raster is never flagged
// IsOpaque — round clips/margins carry alpha 0 — so the row blend always runs.
var op = (int)(System.Math.Clamp(element.Opacity, 0f, 1f) * 256);
if (op <= 0) return;
var px = (int)System.Math.Floor(ex);
var py = (int)System.Math.Floor(ey);
var x0 = System.Math.Max(0, px);
var y0 = System.Math.Max(0, py);
var x1 = System.Math.Min(cropW, px + dw);
var y1 = System.Math.Min(cropH, py + dh);
if (x0 >= x1 || y0 >= y1) return;
var s = raster.BgraPixels;
StripPool.For(y0, y1, y =>
{
var si = ((y - py) * dw + (x0 - px)) * 4;
var len = x1 - x0;
if (op == 256)
BlendRowOpaque(buffer, (y * cropW + x0) * 4, s, si, len);
else
BlendRowWeighted(buffer, (y * cropW + x0) * 4, s, si, len, op);
});
}
/// <summary>Copy <paramref name="frame"/> and blit a global overlay onto the copy
/// at (sx0, sy0) in crop space. internal (not private) because the frame pump's
/// fully-static path needs it: that path stretches the cached bake straight to the
/// output, so any per-frame overlay — social bar, alert ticker, brand flash — has to
/// be composited here first. Copying is the whole point: the bake is cached, and a
/// flash written straight into it would freeze there and never expire.</summary>
internal 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;
}
BlitContentRaw(dst, dstW, dstH, ex, ey, ew, eh, src, opacity, isRound, isMirror);
}
/// <summary>The general scaled/clipped/mirrored sampler (integer fixed-point
/// bilinear + source-over). Also rasterizes paste-cache entries in ELEMENT space
/// onto a TRANSPARENT base (alpha 0) so pasting over the master keeps the round
/// clip and transparency of the untouched margins.</summary>
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,
bool transparentDst = false)
{
if (ew <= 0 || eh <= 0) return;
float scale, ox, oy, invScale;
float drawnW, drawnH;
// No CropBounds means "the whole source canvas is the crop region" — cbW/cbH
// MUST default to the full source dims, not 0. A zero cbW/cbH makes every
// sxCrop/syCrop below evaluate to 0 regardless of position, collapsing the
// entire sampled rect to source pixel (0,0) (the webcam gray-block regression,
// 2026-09-12: introduced by the CropBounds Fill-style scaling work the same
// day — CropBounds-bearing sources, i.e. the web widget, were never affected).
var cbX = 0;
var cbY = 0;
var cbW = src.Width;
var cbH = src.Height;
if (src.CropBounds is { } cb)
{
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;
}
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 * uScale;
drawnH = src.Height * uScale;
}
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;
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)
// Build horizontal LUT for scaled blit to reduce per-pixel divides. The table is
// READ by the strip workers while it is being sampled, so it must not be shared
// mutable state: it comes from the thread-safe ArrayPool and goes back in a finally
// (the static single-buffer version raced the moment the strips went parallel).
var lutCount = x1 - x0 + 1;
var sxLut = ArrayPool<float>.Shared.Rent(lutCount);
try
{
var pxBase = x0 - ex;
for (var xi = 0; xi < lutCount; xi++)
{
var px = pxBase + xi;
float sxCanvas;
if (px < ox || px > ox + drawnW)
{
sxCanvas = float.NaN;
}
else
{
var sxNorm = (px - ox) / drawnW;
sxNorm = sxNorm < 0f ? 0f : sxNorm > 1f ? 1f : sxNorm;
var sxCrop = sxNorm * cbW;
sxCanvas = cbX + sxCrop;
if (sxCanvas < 0f) sxCanvas = 0f;
if (sxCanvas > src.Width - 1f) sxCanvas = src.Width - 1f;
if (isMirror) sxCanvas = src.Width - 1f - sxCanvas;
}
sxLut[xi] = sxCanvas;
}
StripPool.For(y0, y1 + 1, y =>
{
var py = y - ey;
if (py < oy || py > oy + drawnH) return;
// syNorm: normalized [0,1] position within the drawn content (vertically)
var syNorm = (py - oy) / drawnH;
if (syNorm < 0f) syNorm = 0f;
if (syNorm > 1f) syNorm = 1f;
// syCrop: pixel position within the crop region
var syCrop = syNorm * cbH;
// syCanvas: canvas pixel row (for buffer indexing)
var syCanvas = cbY + syCrop;
if (syCanvas < 0f) syCanvas = 0f;
if (syCanvas > src.Height - 1f) syCanvas = 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 xi = 0; xi < lutCount; xi++, di += 4)
{
var sxCanvas = sxLut[xi];
if (float.IsNaN(sxCanvas)) continue;
var px = pxBase + xi;
if (isRound)
{
var dx = px - cx;
var dy = py - cy;
if (dx * dx + dy * dy > radius * radius) continue;
}
var sxi = (int)sxCanvas;
var sx1i = Math.Min(sxi + 1, src.Width - 1);
var fx8 = (int)((sxCanvas - sxi) * 256);
var fxInv = 256 - fx8;
// 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);
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)
{
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;
}
}
}
});
}
finally
{
ArrayPool<float>.Shared.Return(sxLut);
}
}
/// <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;
}
// Opaque source with 1:1 aligned region covering full canvas
if (op == 256 && src.IsOpaque && ex == 0 && ey == 0 && src.Width == dstW && src.Height == 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);
StripPool.For(dy0, 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>
/// The zoom box layer (n+1): map the element's master-space box and capture to
/// crop space and 2x-magnify the capture onto the box from the composed frame.
/// The active zoom element is never blitted by the normal layer loops — the
/// switch skips it and this pass draws it above everything, last.
/// </summary>
private static void BlitZoom(
byte[] dst, int cropW, int cropH, CompositorOptions options, ZoomSceneElement zoom)
{
ZoomMagnifier.MagnifyInto(
dst, cropW, cropH,
zoom.X - options.SourceRectX, zoom.Y - options.SourceRectY,
zoom.Width, zoom.Height,
zoom.CaptureX - options.SourceRectX, zoom.CaptureY - options.SourceRectY,
zoom.ClipShape == ClipShape.Round);
}
/// <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);
}
}