09a866e6a1
Take 9's numbers were decisive: paste cache moved work to ~25ms/frame but the period stayed ~37ms. The missing ~12ms per tick is Task.Delay rounding every sub-tick request up to the Windows system-clock tick (~15.6ms default — documented: learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay). A frame finishing 3ms early requested 3ms and slept 15.6. Producer capped at ~27fps no matter how fast the compositor got — which is why two real render fixes read as 'zero change' in playback. Game-loop/OBS canon for this (stackoverflow.com/questions/5441464; learn.microsoft.com/en-us/windows/win32/ api/timeapi/nf-timeapi-timebeginperiod): raise the timer resolution for the session, sleep only the bulk of the remainder, and SPIN the last ~2ms across the deadline. - FramePump: timeBeginPeriod(1) on entering the pump loop, timeEndPeriod(1) in the finally; pacing = bulk _pacingDelay(ahead - 2ms) + bounded Thread.SpinWait tail; blown deadlines rebase unchanged (never burst). - Stats now report avg wait: render+submit+wait must equal the period — the accounting is closed, no stage can hide in an unmeasured gap again. - Webcam dropped its IsOpaque paste-cache bypass: it re-sampled ~156k px every tick even between identical device frames; cached paste beats the sampler on hits, costs the same on misses. 52/52 per-class green (pacing + pixel suites unchanged — output byte-stable), clean build 0 warnings. Docs same commit (ai.md slice 6, TASKS.md take-10 gate, MyMistakes #3 + renumber, HANDOFF). User's top-bar spec remains next in queue (Unit B) — re-sent many times, captured, no open questions.
672 lines
30 KiB
C#
672 lines
30 KiB
C#
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 — every producer (capture, webcam, chat
|
||
// renderer, web capture, static cache) hands out immutable byte[], so "same pixels
|
||
// array + same target rect" ⇒ same raster. Bounds-checked eviction clears wholesale.
|
||
private sealed record PasteKey(byte[] Pixels, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror);
|
||
private static readonly Dictionary<PasteKey, VideoFrame> _pasteCache = new();
|
||
private static readonly object _pasteGate = new();
|
||
private const int MaxPasteEntries = 48;
|
||
|
||
/// <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;
|
||
// 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);
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
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.Width, frame.Height, dw, dh, isRound, mirror);
|
||
_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.
|
||
var pixels = new byte[dw * dh * 4];
|
||
BlitContentRaw(pixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror);
|
||
raster = new VideoFrame(dw, dh, pixels);
|
||
lock (_pasteGate)
|
||
{
|
||
if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear();
|
||
_pasteCache[key!] = 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);
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
if (ew <= 0 || eh <= 0) return;
|
||
var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
|
||
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);
|
||
}
|
||
}
|