Files
LlamaCasty/ytLive.Tests/MediaVideoSourceTests.cs
T
gramps 5577aaaf91 fix(media): play at the clip's real rate — the pipe was copying a frame per read
Reported as "the default video and the green screen video are playing at a hideously slow
rate… I thought the playback timing issues were resolved?!" They were not: two independent
faults, both in the shared clip pipe.

1. QUADRATIC COPY IN THE FRAME READER. `RawVideoFrameReader` re-concatenated the whole pending
   buffer on every `Feed`, and the pipe feeds it 64 KB at a time. A 1080p BGRA frame is 8 MB, so
   one frame cost ~127 copies of a growing buffer — roughly half a gigabyte of memcpy per frame.
   Measured on a 30 fps clip: 6 frames in 3 seconds (2 fps). The reader now fills one preallocated
   frame buffer and hands that array to the VideoFrame, so a frame is copied exactly once. Pipe
   reads are 256 KB. Same clip: 29 fps.

2. PACING SLEPT ON TOP OF THE WORK. A fixed 1/fps sleep after each frame adds the frame's own
   cost, so 20 ms of work on a 30 fps clip yields 1/(33+20) ≈ 19 fps — measured 20.9. Pacing is
   now deadline-based (`Helpers/FramePacer.cs`): wait only what is left of the frame's interval,
   resync when a frame overruns instead of bursting, never sleep longer than one interval.

The alert clip shares this pipe, so it gained both fixes.

Tests: `FramePacerTests` (subtract work, no drift, late → resync, zero duration, reset);
`ChromaKeyRealClipTests.RealClip_PlaysAtItsNativeRate` (throughput floor of 15 fps on the real
30 fps clip — the old code sat at 2); the two pacing assertions now check "at most one interval"
rather than an exact sleep, which is what deadline pacing means. Full suite: 574 passed,
0 failed, clean build.
2026-11-05 09:57:07 -08:00

165 lines
6.1 KiB
C#

using System.Diagnostics;
using System.IO;
using Xunit;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// TASK 21: the media decoder path. <see cref="RawVideoFrameReader"/> is pure
/// (synthetic bytes, no ffmpeg); the one integration test drives the real
/// <see cref="MediaVideoSource"/> source loop against a fake decoder process.
/// </summary>
public class MediaVideoSourceTests
{
// ─── Pure reader (unit) ───
[Fact]
public void Reader_EmitsOneFramePerFrameBytes()
{
var w = 2; var h = 2;
var reader = new RawVideoFrameReader(w, h);
var frameBytes = new byte[w * h * 4];
var frames = reader.Feed(frameBytes);
Assert.Single(frames);
Assert.Equal(w, frames[0].Width);
Assert.Equal(h, frames[0].Height);
}
[Fact]
public void Reader_SplitsMultipleFramesFromOneFeed()
{
var reader = new RawVideoFrameReader(1, 1); // 4 bytes each
var frames = reader.Feed(new byte[12]); // 3 frames
Assert.Equal(3, frames.Count);
}
[Fact]
public void Reader_KeepsPartialBytesAcrossFeeds()
{
var reader = new RawVideoFrameReader(1, 1);
Assert.Empty(reader.Feed(new byte[2])); // half a frame
Assert.Empty(reader.Feed(new byte[1])); // 3/4
var frames = reader.Feed(new byte[1]); // completes the frame
Assert.Single(frames);
Assert.Empty(reader.Feed(new byte[0])); // no leftover
}
// ─── The one integration test: source loop against a fake decode process ───
[Fact]
public async Task MediaVideoSource_EmitsFramesInOrderFromFakeDecoder()
{
const int w = 2, h = 1; // frame bytes = 8
var frameA = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var frameB = new byte[] { 9, 10, 11, 12, 13, 14, 15, 16 };
var payload = new byte[frameA.Length + frameB.Length];
Buffer.BlockCopy(frameA, 0, payload, 0, frameA.Length);
Buffer.BlockCopy(frameB, 0, payload, frameA.Length, frameB.Length);
using var source = new MediaVideoSource(
"clip.mp4", w, h,
new FakeLocator(),
() => new FakeDecodeProcess(payload));
var seen = new List<byte[]>();
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
source.FrameAvailable += f => seen.Add(((byte[])f.BgraPixels.Clone()));
source.Completed += () => completed.TrySetResult(true);
await source.StartAsync();
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(2, seen.Count);
Assert.Equal(frameA, seen[0]);
Assert.Equal(frameB, seen[1]);
}
private sealed class FakeDecodeProcess : IDecodeProcess
{
private readonly MemoryStream _stream;
public FakeDecodeProcess(byte[] bytes) => _stream = new MemoryStream(bytes);
public void Start(ProcessStartInfo startInfo) { }
public Stream StandardOutput => _stream;
public bool HasExited => _stream.Position >= _stream.Length;
public int ExitCode => 0;
public void Kill() { }
public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask;
public void Dispose() => _stream.Dispose();
}
[Fact]
public async Task MediaVideoSource_PacesFramesByProbedFps()
{
const int w = 2, h = 1; // frame bytes = 8
var payload = new byte[16]; // two frames
var delays = new List<TimeSpan>();
using var source = new MediaVideoSource(
"clip.mp4", w, h,
new FakeLocator(),
() => new FakeDecodeProcess(payload),
frameRateProbe: new FakeFrameRateProbe(1000.0),
delay: (_d, _) => { delays.Add(_d); return Task.CompletedTask; });
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
source.Completed += () => completed.TrySetResult(true);
await source.StartAsync();
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
// One pacing delay per emitted frame, each AT MOST one interval (deadline pacing: the
// frame's own work is subtracted from the wait — 2026-11-05).
Assert.NotEmpty(delays); // the decoder paces at all
foreach (var d in delays)
Assert.True(d > TimeSpan.Zero && d <= TimeSpan.FromMilliseconds(1),
$"expected a wait of at most 1ms, got {d.TotalMilliseconds}ms");
}
[Fact]
public async Task MediaVideoSource_LoopsUntilLoopDisabled()
{
const int w = 1, h = 1; // frame bytes = 4
var payload = new byte[4];
var passCount = 0;
var frames = 0;
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using var source = new MediaVideoSource(
"clip.mp4", w, h,
new FakeLocator(),
() => { passCount++; return new FakeDecodeProcess(payload); });
source.Looping = true;
source.Completed += () => completed.TrySetResult(true);
source.FrameAvailable += _ =>
{
frames++;
if (frames >= 3) source.Looping = false;
};
await source.StartAsync();
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
await source.StopAsync();
// The single frame re-emits across passes (restart) and Completed only
// fires once Looping is cleared.
Assert.True(frames >= 3, $"expected the single frame to re-emit across passes, got {frames}");
Assert.True(passCount >= 3, "Looping should have restarted the decode process");
}
private sealed class FakeFrameRateProbe : IFrameRateProbe
{
private readonly double? _fps;
public FakeFrameRateProbe(double? fps) => _fps = fps;
public Task<double?> ProbeAsync(string path, CancellationToken ct = default)
=> Task.FromResult(_fps);
}
private sealed class FakeLocator : IFfmpegLocator
{
public Task<string> LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
}
}