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
+63
View File
@@ -324,4 +324,67 @@ public class FramePumpTests
Assert.Equal(StreamStatus.Streaming, h.Status);
Assert.Equal(59.9, h.FPS, 1);
}
/// <summary>The ONE integration test for the take-3 starvation fix (2026-09-03):
/// the frame interval is a DEADLINE — render cost eats into it, never piles on
/// top (the OBS libobs video-io.c pacing pattern). The old loop slept the full
/// interval AFTER each render, so 30 wall-seconds of 258ms renders muxed into a
/// 2.1s 60fps time-lapse (rawvideo stamps by arrival). Here the fake render
/// costs ≥60ms of a 200ms interval and the pacing seam records every requested
/// wait: no request may reach the full interval (that was the bug), none may be
/// negative, and a machine slow enough to blow every deadline is still honest
/// (zero waits &lt; interval passes too).</summary>
[Fact]
public async Task Pump_Paces_To_The_Deadline_Compensating_Render_Cost()
{
const int fps = 5; // 200ms interval — generous margin over the fake cost
const int renderCostMs = 60; // Thread.Sleep is a guaranteed lower bound
var interval = TimeSpan.FromSeconds(1d / fps);
var encoder = new FakeEncoder();
var scene = BackgroundScene();
var delays = new List<TimeSpan>();
using var pump = new FramePump(
sceneProvider: () => scene,
frameResolver: _ =>
{
System.Threading.Thread.Sleep(renderCostMs);
return null;
},
compositorOptions: () => new CompositorOptions
{
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48,
OutputWidth = 64, OutputHeight = 48,
},
encoderOptions: () => new EncoderOptions
{
RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
Width = 64, Height = 48, Fps = fps,
},
encoderFactory: () => encoder,
pacingDelay: async (d, ct) =>
{
lock (delays) delays.Add(d);
await Task.Delay(d, ct); // honors the request, like the real Task.Delay default
});
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(10);
while (DateTime.UtcNow < deadline)
{
lock (delays) if (delays.Count >= 3) break;
await Task.Delay(10);
}
await pump.StopAsync();
List<TimeSpan> got;
lock (delays) got = delays.ToList();
Assert.True(got.Count >= 3, $"pump paced only {got.Count} frames in 10s");
foreach (var d in got)
{
Assert.True(d >= TimeSpan.Zero, $"requested wait {d} is negative");
Assert.True(d < interval,
$"requested wait {d} reaches the full {interval} interval — render cost must subtract from the deadline (the take-3 bug)");
}
}
}