fix(pump+compositor): take-3 starvation — deadline pacing + row-blit fast paths

Two defects made the producer 17x slow (37s record -> 2.1s/127-frame file,
rawvideo stamps by arrival): FramePump slept the FULL interval after each
render (period = render+submit+interval) and SceneCompositor did per-pixel
float sampling + Math.Round blends over all 2.07M master pixels, scanning the
whole destination per overlay (258ms avg render vs 1.5ms submit).

Both solutions are established, not invented — researched before coding per
the derivative-work rule:
- deadline pacing: OBS libobs/media-io/video-io.c video_thread (nextTick +=
  intervalTicks, sleep only the remainder, rebase on overrun, never burst)
- row blits: libyuv pattern (BSD-3, chromium.googlesource.com/libyuv/libyuv)
  — 1:1 aligned identity fast path, per-pixel alpha branch, integer
  fixed-point blend, overlay clipped to the intersection rect, skip the dead
  black pre-fill when the backdrop covers

ONE integration test: Pump_Paces_To_The_Deadline_Compensating_Render_Cost
(lands after a fake-seam lesson: pacing fakes must await, not complete
synchronously, or the pump loop runs inline on StartAsync and hangs vstest).
Clean build 0 warnings; FramePumpTests 10/10, SceneCompositor/SceneGraph/
SocialBar/StretchMath 20/20. Docs same commit: ai.md pipeline section,
TASKS.md TASK 18 (webcam-in-output + rename modal verified from take 3),
MyMistakes recipe, HANDOFF rewritten. Take 4 pending on the user's machine.
This commit is contained in:
2026-09-03 09:02:45 -07:00
parent 7fcb2ad3da
commit 716a77f61a
7 changed files with 306 additions and 75 deletions
+119 -18
View File
@@ -54,12 +54,21 @@ public sealed class SceneCompositor
else
{
if (split < 0) split = scene.Elements.Count;
for (var i = 3; i < buffer.Length; i += 4)
buffer[i] = 255; // opaque black base
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);
@@ -281,19 +290,33 @@ public sealed class SceneCompositor
{
if (ew <= 0 || eh <= 0) return;
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 (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height);
// 1:1 aligned fast path (the full-frame backdrop, master-sized overlays):
// bilinear sampling at scale 1 with integer offsets is the identity, so the
// blit is a row walk, not 2M float samples + rounds. Research-first (2026-09-03,
// take-3 fix; libyuv's row-based BLEND_NONE pattern —
// https://chromium.googlesource.com/libyuv/libyuv/): the 258ms/frame render
// that time-lapsed take 3 lived in the general loop below.
if (!isRound && !isMirror && scale == 1f && ox == 0f && oy == 0f
&& ex == MathF.Floor(ex) && ey == MathF.Floor(ey))
{
Blit1To1Rows(dst, dstW, dstH, (int)ex, (int)ey, src, opacity);
return;
}
var drawnW = src.Width * scale;
var drawnH = src.Height * scale;
var radius = Math.Min(ew, eh) / 2f;
var cx = ew / 2f;
var cy = eh / 2f;
var x0 = Math.Max(0, (int)Math.Floor(ex));
var y0 = Math.Max(0, (int)Math.Floor(ey));
var x1 = Math.Min(dstW - 1, (int)Math.Ceiling(ex + ew));
var y1 = Math.Min(dstH - 1, (int)Math.Ceiling(ey + eh));
if (x0 > x1 || y0 > y1) return;
for (var y = y0; y <= y1; y++)
{
for (var x = x0; x <= x1; x++)
@@ -317,6 +340,83 @@ public sealed class SceneCompositor
}
}
/// <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;
var rowLen = x1 - x0;
for (var y = y0; y < y1; y++)
{
var si = ((y - ey) * src.Width + (x0 - ex)) * 4;
var di = (y * dstW + x0) * 4;
if (op == 256) BlendRowOpaque(dst, di, src.BgraPixels, si, rowLen);
else BlendRowWeighted(dst, di, src.BgraPixels, si, rowLen, op);
}
}
private static void BlendRowOpaque(byte[] dst, int di, 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(byte[] dst, int di, 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,
@@ -351,21 +451,22 @@ public sealed class SceneCompositor
}
/// <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).</summary>
/// 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)
{
for (var y = 0; y < dstH; y++)
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++)
{
for (var x = 0; x < dstW; x++)
{
var sx = x + options.SourceRectX - sx0;
var sy = y + options.SourceRectY - sy0;
if (sx < 0 || sy < 0 || sx >= overlay.Width || sy >= overlay.Height) continue;
var sample = StretchMath.SampleBgra(overlay.BgraPixels, overlay.Width, overlay.Height, sx, sy);
BlendPixel(dst, (y * dstW + x) * 4, sample, 1f);
}
var si = ((y + options.SourceRectY - sy0) * overlay.Width
+ dx0 + options.SourceRectX - sx0) * 4;
BlendRowOpaque(dst, (y * dstW + dx0) * 4, overlay.BgraPixels, si, dx1 - dx0);
}
}
+25 -1
View File
@@ -218,6 +218,16 @@ public sealed class FramePump : IDisposable
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
var lastTick = System.Diagnostics.Stopwatch.StartNew();
// Deadline pacing (2026-09-03, take-3 fix): the frame interval is a DEADLINE,
// not an afterthought sleep — the OBS libobs video-io.c pattern (researched
// before coding; see https://github.com/obsproject/obs-studio/blob/master/
// libobs/media-io/video-io.c). The old loop slept the FULL interval after
// each render, so period = render + submit + interval: at take-3's 258ms
// render that was 3.6fps stamped into a 60fps container — rawvideo stamps by
// arrival, so 30 wall-seconds muxed as a 2.1s time-lapse, no error anywhere.
var intervalTicks = Math.Max(1, (long)Math.Round(interval.TotalSeconds * System.Diagnostics.Stopwatch.Frequency));
var nextTick = System.Diagnostics.Stopwatch.GetTimestamp();
// Stage timing (2026-09-01, take two): rawvideo carries no per-frame
// timestamps — ffmpeg stamps frames by ARRIVAL at the declared fps. A producer
// slower than the declared rate yields a time-lapsed, short file (observed:
@@ -292,8 +302,22 @@ public sealed class FramePump : IDisposable
submitTicks += submitSw.ElapsedTicks;
statFrames++;
ReportStats();
// Advance the deadline; cost already spent is not slept again.
// Blew the frame budget: skip the wait AND the missed ticks —
// rebase the clock rather than bursting a catch-up pile
// (OBS rewinds its tick the same way; a burst would only
// queue stale frames into the encoder).
nextTick += intervalTicks;
var lag = nextTick - System.Diagnostics.Stopwatch.GetTimestamp();
if (lag <= 0)
{
nextTick = System.Diagnostics.Stopwatch.GetTimestamp() + intervalTicks;
lag = 0;
}
await _pacingDelay(
TimeSpan.FromSeconds(lag / (double)System.Diagnostics.Stopwatch.Frequency), ct);
}
await _pacingDelay(interval, ct);
}
}
catch (OperationCanceledException)