Files
LlamaCasty/Services/MediaVideoSource.cs
gramps 881addb5b4 TASK 21 slice 3: loop control in MediaVideoSource (process factory + Loop flag)
- IMediaFrameSource gains bool Looping.
- MediaVideoSource ctor takes Func<IDecodeProcess> processFactory instead of a
  single IDecodeProcess: a System.Diagnostics.Process can't be re-Start()ed, so
  each loop pass creates a fresh decoder. Decode wrapped in do-while(Looping):
  restart on natural EOF instead of raising Completed.
- Production wiring (MainViewModel media factory): passes the process factory
  AND FfmpegFrameRateProbe -- closes the slice-2b gap where production had no
  probe and therefore no pacing.
- Tests: loop test (single frame re-emits across passes, Completed only when
  loop cleared); fakes updated for the new interface member. Media tests 12/12,
  build 0 warnings.

Wiring Source.MediaIsLooping into the flag needs a manager-level per-path loop
provider -> lands with the UI-picker (acquisition) slice.

Derivative reference: looping media by restarting decode on EOF, standard in
playback/overlay tooling (OBS media source repeat).
2026-08-31 19:52:10 -07:00

148 lines
4.9 KiB
C#

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. Implements
/// <see cref="IMediaFrameSource"/>: raises <see cref="FrameAvailable"/> per
/// frame and <see cref="IMediaFrameSource.Completed"/> on natural EOF (unless
/// <see cref="IMediaFrameSource.Looping"/>). Spawns ffmpeg with
/// <c>-f rawvideo -pix_fmt bgra</c> and drains the raw BGRA pipe via
/// <see cref="RawVideoFrameReader"/>. The decoder process and emitters are seams
/// so tests run without a real codec or audio device.
/// </summary>
public sealed class MediaVideoSource : IMediaFrameSource
{
public event Action<VideoFrame>? FrameAvailable;
public event Action? Completed;
public string Key => _path;
public bool Looping { get; set; }
private readonly string _path;
private readonly int _width;
private readonly int _height;
private readonly IFfmpegLocator _locator;
private readonly Func<IDecodeProcess> _processFactory;
private readonly IFrameRateProbe? _probe;
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
private readonly CancellationTokenSource _cts = new();
private IDisposable? _run;
private IDecodeProcess? _active;
public MediaVideoSource(
string path,
int width,
int height,
IFfmpegLocator locator,
Func<IDecodeProcess> processFactory,
IFrameRateProbe? frameRateProbe = null,
Func<TimeSpan, CancellationToken, Task>? delay = null)
{
_path = path;
_width = width;
_height = height;
_locator = locator;
_processFactory = processFactory;
_probe = frameRateProbe;
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
}
public Task StartAsync(CancellationToken cancellationToken = default)
{
if (_run != null) return Task.CompletedTask;
_run = _cts.Token.Register(StopInternal);
_ = Task.Run(RunAsync, CancellationToken.None);
return Task.CompletedTask;
}
public Task StopAsync()
{
_cts.Cancel();
_run = null;
return Task.CompletedTask;
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
_active?.Dispose();
}
private void StopInternal() => KillProcess();
private async Task RunAsync()
{
try
{
double? frameRate = null;
if (_probe != null)
frameRate = await _probe.ProbeAsync(_path, _cts.Token).ConfigureAwait(false);
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
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,
};
do
{
using (var process = _processFactory())
{
_active = process;
process.Start(startInfo);
var reader = new RawVideoFrameReader(_width, _height);
var buffer = new byte[65536];
var stream = process.StandardOutput;
while (!_cts.IsCancellationRequested && !process.HasExited)
{
var read = await stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token).ConfigureAwait(false);
if (read <= 0) break;
foreach (var frame in reader.Feed(buffer.AsSpan(0, read).ToArray()))
{
FrameAvailable?.Invoke(frame);
if (frameDuration > TimeSpan.Zero)
await _delay(frameDuration, _cts.Token).ConfigureAwait(false);
}
}
}
_active = null;
}
while (Looping && !_cts.IsCancellationRequested);
}
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 { _active?.Kill(); } catch { /* already exited */ }
}
}