8fa78423e6
- 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.
68 lines
2.6 KiB
C#
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);
|
|
}
|
|
}
|