using ytLive.Models; namespace ytLive.Services.Compositor; /// /// The output compositor: renders a scene into the encoder's master frame (tightly-packed /// BGRA8 ) 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 resolver /// (the caller maps webcam → DeviceId, images → AssetId, background → CaptureKey), keeping /// the compositor pure and free of WPF and of the capture managers. /// 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 _pasteCache = new(); private static readonly Dictionary _elementRasters = new(ReferenceEqualityComparer.Instance); private static readonly object _pasteGate = new(); private const int MaxPasteEntries = 256; /// /// Composite into the tier's output frame. Layer order (back → /// front): live background (the scene's IsBackground source) → background image → /// visible elements (z-order = Elements order, mirroring the XAML DataTemplate) → /// branding flash → social bar (a global overlay; spans the master width at /// ). Transparent regions read opaque black. /// public VideoFrame Render( Scene scene, Func 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().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().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); } /// /// Render the static base of a scene: the opaque black layer, the live-capture /// background, the background image, and every z-layers /// below the split point (all of which are static by construction — see /// ). The result is a source-rect sized frame that /// can start from and composite the dynamic/above-split layers /// onto each tick. Returns null when there is no static layer at all. /// public static VideoFrame? BakeStaticBase( Scene scene, int split, Func frameFor, Func 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().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().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; } /// The compositor's dynamic/above-split path: start from an existing frame /// buffer and composite the z-layers from onward on top. /// When is null the caller must fall back to full render. public static VideoFrame CompositeLayers( VideoFrame baseFrame, Scene scene, int split, Func 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; // 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); if (element.HasBorder) DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound); } /// 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. private static void BlitCachedLayer( byte[] buffer, int cropW, int cropH, float ex, float ey, float ew, float eh, VideoFrame frame, SceneElement element, bool isRound) { 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) { // 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); 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; } } // 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 d = buffer.AsSpan(); var s = raster.BgraPixels; for (var y = y0; y < y1; y++) { var si = ((y - py) * dw + (x0 - px)) * 4; var len = x1 - x0; if (op == 256) BlendRowOpaque(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len); else BlendRowWeighted(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len, op); } } 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); } /// /// Render a cross-fade blend between two scenes. Used by /// 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). /// 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); } /// /// 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. /// 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); } /// 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. 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) { if (ew <= 0 || eh <= 0) return; float scale, ox, oy, invScale; float drawnW, drawnH; var cbX = 0; var cbY = 0; var cbW = 0; var cbH = 0; 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) for (var y = y0; y <= y1; y++) { var py = y - ey; if (py < oy || py > oy + drawnH) continue; // 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) { var px = x - ex; 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; } // 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; // 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) { 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; } } } } /// 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). 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; } /// 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. 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 dst, int di, ReadOnlySpan 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 dst, int di, ReadOnlySpan 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; } } /// Centered OBS-style border stroke: rect ring (Traditional) or circle ring (Round). 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); } } } /// 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). 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); } } /// Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage. 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); } }