Files
LlamaCasty/Services/FrameRateProbe.cs
T
gramps 8fa78423e6 TASK 21 slice 2a: native-FPS probe seam (ffprobe parse + derive sibling)
- FfmpegFrameRateParser (pure): prefers avg_frame_rate= then r_frame_rate=,
  rational N/N/M, unknown/0 -> null.
- IFrameRateProbe + FfmpegFrameRateProbe: derives sibling ffprobe.exe from the
  located ffmpeg dir, reuses the IDecodeProcess seam for the ffprobe subprocess
  text; null if ffprobe absent.
- FfmpegLocator now also extracts ffprobe.exe (ProbeFileName) from the pinned
  archive, conditional so old caches without it degrade to no pacing.
- Tests: 6 pure parser units + 1 probe integration via fake locator/process;
  FfmpegLocatorTests still green. 15/15, 0 warnings.

Pacing (probe->delay) is slice 2b. Derivative reference: standard ffprobe
avg_frame_rate probing used across OBS/media tooling.
2026-08-31 19:45:48 -07:00

68 lines
2.6 KiB
C#

using System.Diagnostics;
using System.IO;
using ytLive.Services.Encoder;
namespace ytLive.Services;
/// <summary>
/// TASK 21 (slice 2): resolves the native frame rate of a media file so the
/// decoder can pace frames to real time. Seam so <see cref="MediaVideoSource"/>
/// never touches ffprobe directly and tests can fake the whole probe.
/// </summary>
public interface IFrameRateProbe
{
/// <summary>Native video FPS for <paramref name="path"/>, or null when unknown
/// (no video stream, ffprobe unavailable, unparsable).</summary>
Task<double?> ProbeAsync(string path, CancellationToken cancellationToken = default);
}
/// <summary>
/// Default probe: locates ffmpeg (via <see cref="IFfmpegLocator"/>), derives the
/// sibling <c>ffprobe.exe</c> from its directory, and asks ffprobe for the
/// <c>avg_frame_rate</c>/<c>r_frame_rate</c>. Reuses the binary-stdout
/// <see cref="IDecodeProcess"/> seam for the subprocess (ffprobe text is read from
/// the same raw stdout). Returns null (no pacing) if ffprobe is unavailable.
/// </summary>
public sealed class FfmpegFrameRateProbe : IFrameRateProbe
{
private readonly IFfmpegLocator _locator;
private readonly Func<IDecodeProcess> _probeProcessFactory;
public FfmpegFrameRateProbe(IFfmpegLocator locator, Func<IDecodeProcess> probeProcessFactory)
{
_locator = locator;
_probeProcessFactory = probeProcessFactory;
}
public async Task<double?> ProbeAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path)) return null;
var ffmpegPath = await _locator.LocateAsync(cancellationToken).ConfigureAwait(false);
var ffprobePath = Path.Combine(Path.GetDirectoryName(ffmpegPath) ?? string.Empty, "ffprobe.exe");
if (!File.Exists(ffprobePath)) return null;
var startInfo = new ProcessStartInfo
{
FileName = ffprobePath,
Arguments =
$"-v error -select_streams v:0 " +
$"-show_entries stream=avg_frame_rate,r_frame_rate " +
$"-of default=noprint_wrappers=1 \"{path}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = _probeProcessFactory();
process.Start(startInfo);
string output;
using (var reader = new StreamReader(process.StandardOutput))
output = reader.ReadToEnd();
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
return FfmpegFrameRateParser.ParseFrameRate(output);
}
}