TASK 21 Increment B: FFmpeg rawvideo video decoder -> VideoFrame
Adds the codec-agnostic decoder half of the media source. Spawns ffmpeg with -f rawvideo -pix_fmt bgra (reusing the already-shipped ffmpeg via IFfmpegLocator) and drains the raw BGRA stdout pipe into VideoFrames. - Services/RawVideoFrameReader.cs: pure rawvideo BGRA stream -> frames (partial reads kept across Feed; no ffmpeg needed to test) - Services/IDecodeProcess.cs + FfmpegDecodeProcess.cs: binary-stdout subprocess seam, mirror of the encoder's IEncoderProcess - Services/MediaVideoSource.cs: owns the decode, raises FrameReady/Completed - MediaVideoSourceTests: 3 pure reader + 1 integration (fake decode process through the real source loop, frames in order) Reference (external scan): ffmpeg rawvideo pipe decode is the canonical codec-agnostic frame feeds pattern (ffmpeg docs -f rawvideo; how OBS/media pipelines push frames to a compositor). Verified: 4/4 tests, build 0 warnings. Native-FPS pacing + resolver/compositor wiring are the next slice.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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.FrameReady += f => seen.Add(((byte[])f.BgraPixels.Clone()));
|
||||
source.Completed += () => completed.TrySetResult(true);
|
||||
|
||||
source.Start();
|
||||
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();
|
||||
}
|
||||
|
||||
private sealed class FakeLocator : IFfmpegLocator
|
||||
{
|
||||
public Task<string> LocateAsync(CancellationToken ct = default) => Task.FromResult("ffmpeg.exe");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user