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:
@@ -20,6 +20,11 @@ public sealed class FfmpegLocator : IFfmpegLocator
|
||||
{
|
||||
public const string FileName = "ffmpeg.exe";
|
||||
|
||||
/// <summary>Sibling probe binary — extracted alongside ffmpeg so the media
|
||||
/// frame-rate probe (<see cref="ytLive.Services.FfmpegFrameRateProbe"/>) can
|
||||
/// derive its path from the located ffmpeg directory.</summary>
|
||||
public const string ProbeFileName = "ffprobe.exe";
|
||||
|
||||
/// <summary>Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md).</summary>
|
||||
public const string PinnedUrl =
|
||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip";
|
||||
@@ -85,6 +90,10 @@ public sealed class FfmpegLocator : IFfmpegLocator
|
||||
e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}.");
|
||||
ExtractOne(exe, Path.Combine(staging, FileName));
|
||||
var probe = archive.Entries.FirstOrDefault(
|
||||
e => e.FullName.EndsWith("/" + ProbeFileName, StringComparison.OrdinalIgnoreCase));
|
||||
if (probe != null)
|
||||
ExtractOne(probe, Path.Combine(staging, ProbeFileName));
|
||||
foreach (var entry in archive.Entries)
|
||||
{
|
||||
if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21 (slice 2): parses the native frame rate of a video from ffprobe
|
||||
/// <c>-show_entries stream=avg_frame_rate,r_frame_rate</c> output, which reports
|
||||
/// each as a rational like <c>30/1</c> or <c>60000/1001</c> (or <c>0/0</c> when
|
||||
/// unknown). Prefers <c>avg_frame_rate</c> (the true display rate over the whole
|
||||
/// file, correct for VFR) and falls back to <c>r_frame_rate</c> (the container/
|
||||
/// codec rate). Pure: no ffprobe, no I/O, unit-testable.
|
||||
/// </summary>
|
||||
public static class FfmpegFrameRateParser
|
||||
{
|
||||
public static double? ParseFrameRate(string probeOutput)
|
||||
{
|
||||
foreach (var key in new[] { "avg_frame_rate=", "r_frame_rate=" })
|
||||
{
|
||||
var value = FindValue(probeOutput, key);
|
||||
if (value is not null && TryParseRational(value, out var fps))
|
||||
return fps;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The trimmed value of the first line beginning with <paramref name="key"/>.</summary>
|
||||
private static string? FindValue(string text, string key)
|
||||
{
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith(key, StringComparison.Ordinal))
|
||||
return trimmed.Substring(key.Length).Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Parses <c>v</c> as <c>N</c> (integer) or <c>N/M</c> (rational) into
|
||||
/// a frames-per-second double. <c>0</c> and <c>0/M</c> count as unknown.</summary>
|
||||
private static bool TryParseRational(string value, out double fps)
|
||||
{
|
||||
fps = 0;
|
||||
var parts = value.Split('/');
|
||||
if (parts.Length == 0 || !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var num))
|
||||
return false;
|
||||
var den = 1;
|
||||
if (parts.Length > 1 && !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out den))
|
||||
return false;
|
||||
if (num <= 0 || den <= 0) return false;
|
||||
fps = (double)num / den;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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