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.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user