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:
2026-08-31 18:57:29 -07:00
parent f89b9f9ffa
commit 8f490102ee
8 changed files with 383 additions and 40 deletions
+36
View File
@@ -0,0 +1,36 @@
using System.Diagnostics;
using System.IO;
namespace ytLive.Services;
/// <summary>
/// The real <see cref="IDecodeProcess"/>: a <see cref="Process"/> with stdout
/// redirected as raw bytes for reading <c>rawvideo</c> frames. Constructed by
/// <see cref="MediaVideoSource"/> for the media-file decoder subprocess.
/// </summary>
public sealed class FfmpegDecodeProcess : IDecodeProcess
{
private readonly Process _process;
public FfmpegDecodeProcess() => _process = new Process { EnableRaisingEvents = true };
public void Start(ProcessStartInfo startInfo)
{
_process.StartInfo = startInfo;
_process.Start();
}
public Stream StandardOutput => _process.StandardOutput.BaseStream;
public bool HasExited => _process.HasExited;
public int ExitCode => _process.ExitCode;
public void Kill()
{
if (!_process.HasExited) _process.Kill();
}
public Task WaitForExitAsync(CancellationToken cancellationToken = default)
=> _process.WaitForExitAsync(cancellationToken);
public void Dispose() => _process.Dispose();
}
+24
View File
@@ -0,0 +1,24 @@
using System.Diagnostics;
using System.IO;
namespace ytLive.Services;
/// <summary>
/// TASK 21: seam around the media decoder's subprocess (ffmpeg <c>rawvideo</c>).
/// Exposes the raw binary stdout (frames) and exit control, so
/// <see cref="MediaVideoSource"/> never touches <c>System.Diagnostics.Process</c>
/// directly and tests can fake the whole decode. Mirrors the encoder's
/// <c>IEncoderProcess</c> but with a binary <see cref="StandardOutput"/>.
/// </summary>
public interface IDecodeProcess : IDisposable
{
void Start(ProcessStartInfo startInfo);
/// <summary>Raw binary stdout — the <c>rawvideo</c> BGRA frame bytes.</summary>
Stream StandardOutput { get; }
bool HasExited { get; }
int ExitCode { get; }
void Kill();
Task WaitForExitAsync(CancellationToken cancellationToken = default);
}
+117
View File
@@ -0,0 +1,117 @@
using System.Diagnostics;
using System.IO;
using ytLive.Services.Encoder;
namespace ytLive.Services;
/// <summary>
/// TASK 21: decodes a local media file (any format ffmpeg handles) into
/// <see cref="VideoFrame"/>s for a MediaSource. Spawns ffmpeg with
/// <c>-f rawvideo -pix_fmt bgra</c>, reads the raw BGRA pipe via
/// <see cref="RawVideoFrameReader"/>, and raises <see cref="FrameReady"/> per
/// frame. The decoder process is a seam (<see cref="IDecodeProcess"/>) so tests
/// run without a real codec or audio device. Frames are emitted as fast as the
/// pipe produces them; native-FPS pacing is wired by the caller via a probe in a
/// later unit (TASK 21 step 5 pacing).
/// </summary>
public sealed class MediaVideoSource : IDisposable
{
public event Action<VideoFrame>? FrameReady;
public event Action? Completed;
private readonly string _path;
private readonly int _width;
private readonly int _height;
private readonly IFfmpegLocator _locator;
private readonly IDecodeProcess _process;
private readonly CancellationTokenSource _cts = new();
private IDisposable? _run;
public MediaVideoSource(
string path,
int width,
int height,
IFfmpegLocator locator,
IDecodeProcess process)
{
_path = path;
_width = width;
_height = height;
_locator = locator;
_process = process;
}
public void Start()
{
if (_run != null) return;
_run = _cts.Token.Register(StopInternal);
_ = Task.Run(RunAsync);
}
public void Stop()
{
_cts.Cancel();
_run = null;
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
_process.Dispose();
}
private void StopInternal() => KillProcess();
private async Task RunAsync()
{
try
{
var ffmpegPath = await _locator.LocateAsync(_cts.Token).ConfigureAwait(false);
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
_process.Start(startInfo);
var reader = new RawVideoFrameReader(_width, _height);
var buffer = new byte[65536];
var stream = _process.StandardOutput;
byte[] readChunk;
while (!_cts.IsCancellationRequested && !_process.HasExited)
{
var read = await stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token).ConfigureAwait(false);
if (read <= 0) break;
readChunk = buffer.AsSpan(0, read).ToArray();
foreach (var frame in reader.Feed(readChunk))
FrameReady?.Invoke(frame);
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
Fail($"Media decode failed: {ex.Message}");
return;
}
if (!_cts.IsCancellationRequested)
Completed?.Invoke();
}
private void Fail(string message) => Console.Error.WriteLine(message);
private void KillProcess()
{
try { _process.Kill(); } catch { /* already exited */ }
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace ytLive.Services;
/// <summary>
/// TASK 21: parses a stream of tightly-packed raw BGRA frames into
/// <see cref="VideoFrame"/>s. The consumer of an ffmpeg <c>-f rawvideo -pix_fmt
/// bgra</c> stdout pipe: every frame is exactly <c>width*height*4</c> bytes, so a
/// frame is emitted whenever that many bytes have accumulated. Pure and fully
/// deterministic, so it is tested with a synthetic byte stream — no ffmpeg, no
/// process, no audio device needed.
/// </summary>
public sealed class RawVideoFrameReader
{
private readonly int _frameBytes;
private readonly int _width;
private readonly int _height;
private byte[]? _pending;
public RawVideoFrameReader(int width, int height)
{
_width = width;
_height = height;
_frameBytes = width * height * 4;
}
/// <summary>Feeds <paramref name="bytes"/> read from the pipe and returns any
/// whole frames reconstructed so far. Partial/pending bytes are kept for the
/// next call — frames never split or merge across reads.</summary>
public List<VideoFrame> Feed(byte[] bytes)
{
var pending = _pending;
byte[] combined;
if (pending != null && pending.Length > 0)
{
combined = new byte[pending.Length + bytes.Length];
Buffer.BlockCopy(pending, 0, combined, 0, pending.Length);
Buffer.BlockCopy(bytes, 0, combined, pending.Length, bytes.Length);
_pending = null;
}
else
{
combined = bytes;
}
var frames = new List<VideoFrame>();
var offset = 0;
while (combined.Length - offset >= _frameBytes)
{
var frame = new byte[_frameBytes];
Buffer.BlockCopy(combined, offset, frame, 0, _frameBytes);
frames.Add(new VideoFrame(_width, _height, frame));
offset += _frameBytes;
}
if (offset < combined.Length)
_pending = combined[offset..];
return frames;
}
}