UNIT 0: make a dropped webcam frame measurable, not just inferable

The creator is DHH and lipreads their own recordings, so a webcam that
judders is an accessibility defect, not a polish item. Take
ty-20261005-1001-821-0001.mp4 holds 25.6 distinct webcam images/s against
the camera's 30fps with 281 gaps of 50-100ms - roughly one hitch in five -
in a file that muxes flawlessly. Nothing in the app could see that: rawvideo
carries no per-frame timestamps (ffmpeg stamps by ARRIVAL at the declared
60fps), and the stats line divided every average by the number of frames
SUBMITTED, which deadline padding always fills to the full target. So the
pump reported "300/300 frames, avg render 12.4ms" while producing 60
distinct composites.

Helpers/FrameCadence.cs is one pure counter fed from three points:
MediaCaptureFrameSource logs the device's own SystemRelativeTime stamps
against our arrival times (TryAcquireLatestFrame keeps only the LATEST
frame, so falling behind is silent by design), and FramePump logs the
distinct webcam Epoch values that reached a composite. Read together they
name the culprit - device/USB, our reader, or our compositor - instead of
guessing. Render/resolve/wait now divide by the real render count, and
"frames per 5s from R renders" is the duplicate-on-lag ratio stated
honestly.

An out-of-order timestamp counts as a frame but produces no gap and does not
become the next interval's reference, so one bad stamp cannot invent a
multi-second hitch twice.

Also corrects a stale ai.md line (the "webcam renders at ~18fps" figure
came from a different session and counted every newly rendered layer) and
re-records the background-removal ruling as permanently out. Records the
agreed-but-unbuilt Units 1-2, the no-fallback-camera ruling, and the
SetFormatAsync dead end found by probing the 19041 projection.

Suite 402/402, 0 warnings. The one red, LayerReorderPersistenceTests'
RealMouseDrag, fails identically on a stashed clean tree - it needs an
interactive desktop session this shell lacks.
This commit is contained in:
2026-10-05 11:10:22 -07:00
parent b2f219739d
commit 55ca1029a3
9 changed files with 532 additions and 30 deletions
+47
View File
@@ -1,5 +1,6 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Xunit;
using ytLive.Models;
using ytLive.Services;
@@ -573,6 +574,52 @@ public class FramePumpTests
$"resolver ran on the starting thread ({startThread}) — the loop is back on the caller context"));
}
/// <summary>The ONE integration test for UNIT 0's stats correction (2026-10-05).
/// The old line divided every average by the number of frames SUBMITTED — deadline
/// padding always fills that to the full 60fps target, so a pump fed one webcam
/// frame per 200ms still logged "300/300 frames per 5s" and the "avg render" was
/// diluted by an order of magnitude. It also never reported the webcam at all, so
/// a camera delivering 25 of its 30 frames was indistinguishable from one
/// delivering 30. Here a webcam advancing a new Epoch every ~200ms must show up
/// as FEWER distinct frames than submits, with the render count beside them.</summary>
[Fact]
public async Task Stats_ReportRealRendersAndDistinctWebcamFrames_NotJustPaddedSubmits()
{
var webcamPixels = SceneCompositorTests.Solid(64, 48, 0, 255, 0).BgraPixels;
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new WebcamSceneConfig { Name = "Webcam", IsVisible = true });
var encoder = new FakeEncoder();
var log = new List<string>();
var epoch = 0L;
using var pump = NewPump(encoder, scene: () => scene, log: log, resolve: e =>
{
if (!e.IsWebcam) return null;
// One NEW webcam frame every ~200ms — slower than the 60fps container,
// exactly the case the old stats line hid completely.
Thread.Sleep(200);
epoch++;
return new VideoFrame(64, 48, webcamPixels) { Epoch = epoch };
});
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(8);
while (DateTime.UtcNow < deadline && !log.Any(l => l.Contains("webcam ")))
await Task.Delay(50);
await pump.StopAsync();
var stats = log.FirstOrDefault(l => l.StartsWith("FramePump stats:") && l.Contains("webcam "));
Assert.True(stats != null, $"no cadence-bearing stats line in: {string.Join(" | ", log)}");
var submits = int.Parse(Regex.Match(stats!, @"stats: (\d+)/").Groups[1].Value);
var renders = int.Parse(Regex.Match(stats!, @"from (\d+) renders").Groups[1].Value);
var webcamFrames = int.Parse(Regex.Match(stats!, @"webcam (\d+)f/").Groups[1].Value);
Assert.True(submits > 0, stats);
Assert.True(renders > 0 && renders <= submits, $"renders {renders} vs submits {submits}: {stats}");
Assert.True(webcamFrames > 0 && webcamFrames < submits,
$"webcam frames {webcamFrames} should be far fewer than the {submits} padded submits: {stats}");
}
private sealed class InlineSyncContext : SynchronizationContext
{
public override void Post(SendOrPostCallback d, object? state) => d(state);