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.
111 lines
3.8 KiB
C#
111 lines
3.8 KiB
C#
using System;
|
|
using System.Diagnostics;
|
|
using Xunit;
|
|
using ytLive.Helpers;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// UNIT 0 (2026-10-05): the camera and the pump could both drop frames invisibly —
|
|
/// a 60fps rawvideo container stamps frames by ARRIVAL, so a missing camera frame
|
|
/// and a padded repeat look identical in the recorded file, and the old stats line
|
|
/// counted SUBMITTED frames (always the full padded target) over an unknown number
|
|
/// of renders. These tests pin the one shared counter that makes the two measurable:
|
|
/// gaps, hitches, and the longest stall in a window of a timestamped stream.
|
|
/// </summary>
|
|
public sealed class FrameCadenceTests
|
|
{
|
|
private static long Ms(double ms) => (long)(ms * Stopwatch.Frequency / 1000d);
|
|
|
|
private static long AtMs(double ms) => Ms(ms);
|
|
|
|
[Fact]
|
|
public void SteadyStream_ReportsNominalRateAndNoHitches()
|
|
{
|
|
var cadence = new FrameCadence();
|
|
|
|
// 30 frames at a true 33.3ms period: 29 gaps, none over the 50ms threshold.
|
|
for (var i = 0; i < 30; i++) cadence.Add(AtMs(i * (1000d / 30d)));
|
|
|
|
var s = cadence.Summarize();
|
|
Assert.Equal(30, s.Frames);
|
|
Assert.Equal(29, s.Gaps);
|
|
Assert.Equal(0, s.Hitches);
|
|
Assert.Equal(30d, Math.Round(s.RatePerSecond, 0));
|
|
Assert.Equal(33.3, Math.Round(s.AverageGapMs, 1));
|
|
Assert.InRange(s.LongestGapMs, 33.0, 33.5);
|
|
}
|
|
|
|
[Fact]
|
|
public void DroppedFrames_CountAsHitchesAndLongestGap()
|
|
{
|
|
var cadence = new FrameCadence();
|
|
|
|
// 30fps with two dropped frames (66.6ms gaps) and one double-frame (0.1ms):
|
|
// the pattern the 2026-10-05 recording showed in the webcam corner.
|
|
foreach (var ms in new[]
|
|
{
|
|
0.0, 33.3, 33.4, 66.7, 100.0, 133.3, 199.9, 233.2, 266.5, 333.1, 366.4,
|
|
})
|
|
cadence.Add(AtMs(ms));
|
|
|
|
var s = cadence.Summarize();
|
|
Assert.Equal(11, s.Frames);
|
|
Assert.Equal(10, s.Gaps); // the bunched pair still counts as an interval
|
|
Assert.Equal(2, s.Hitches); // the two dropped-frame gaps at 30fps
|
|
Assert.Equal(66.6, Math.Round(s.LongestGapMs, 1));
|
|
}
|
|
|
|
[Fact]
|
|
public void OutOfOrderStamp_CountsAsFrameWithoutPoisoningGaps()
|
|
{
|
|
var cadence = new FrameCadence();
|
|
|
|
cadence.Add(AtMs(0));
|
|
cadence.Add(AtMs(33.3));
|
|
cadence.Add(AtMs(10.0)); // out of order — a stamp from another clock domain
|
|
cadence.Add(AtMs(66.6));
|
|
|
|
var s = cadence.Summarize();
|
|
Assert.Equal(4, s.Frames);
|
|
Assert.Equal(2, s.Gaps); // the two valid intervals only
|
|
Assert.Equal(0, s.Hitches);
|
|
Assert.Equal(33.3, Math.Round(s.LongestGapMs, 1));
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_StartsFreshWindowAndKeepsThreshold()
|
|
{
|
|
var cadence = new FrameCadence(hitchThresholdMs: 20);
|
|
|
|
cadence.Add(AtMs(0));
|
|
cadence.Add(AtMs(100));
|
|
Assert.Equal(1, cadence.Hitches);
|
|
|
|
cadence.Reset();
|
|
|
|
Assert.Equal(0, cadence.Frames);
|
|
Assert.Equal(0, cadence.Gaps);
|
|
Assert.Equal(20, cadence.Summarize().HitchThresholdMs);
|
|
Assert.Contains("no frames", cadence.Summarize().Describe("cam"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Describe_NamesTheWindowRateHitchesAndLongestGap()
|
|
{
|
|
var cadence = new FrameCadence();
|
|
|
|
foreach (var ms in new[] { 0.0, 16.7, 33.4, 50.1, 120.0 }) cadence.Add(AtMs(ms));
|
|
|
|
Assert.Equal(
|
|
"webcam 5f/0.1s 33.3/s, 4 gaps, 1 >= 50ms, avg 30.0ms, longest 69.9ms",
|
|
cadence.Summarize().Describe("webcam"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ThresholdsMustBePositive()
|
|
{
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new FrameCadence(0));
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => new FrameCadence(-1));
|
|
}
|
|
} |