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,116 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// TASK 21 (slice 2): the ffprobe frame-rate parser is pure (synthetic text, no
|
||||
/// probe); the one integration test drives the real <see cref="FfmpegFrameRateProbe"/>
|
||||
/// against a fake locator + fake probe subprocess end to end.
|
||||
/// </summary>
|
||||
public class FfmpegFrameRateParserTests
|
||||
{
|
||||
// ─── Pure parser (unit) ───
|
||||
|
||||
[Fact]
|
||||
public void Parse_AvgFrameRate_Rational()
|
||||
{
|
||||
var out0 = "codec_name=h264\navg_frame_rate=30/1\nr_frame_rate=30/1\n";
|
||||
Assert.Equal(30.0, FfmpegFrameRateParser.ParseFrameRate(out0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_AvgFrameRate_29_97()
|
||||
{
|
||||
var out0 = "avg_frame_rate=30000/1001\nr_frame_rate=30/1\n";
|
||||
Assert.Equal(30000.0 / 1001.0, FfmpegFrameRateParser.ParseFrameRate(out0)!.Value, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_PrefersAvgOverRFrame()
|
||||
{
|
||||
var out0 = "avg_frame_rate=25/1\nr_frame_rate=30000/1001\n";
|
||||
Assert.Equal(25.0, FfmpegFrameRateParser.ParseFrameRate(out0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_FallsBackToRFrame_WhenAvgUnknown()
|
||||
{
|
||||
var out0 = "avg_frame_rate=0/0\nr_frame_rate=50/1\n";
|
||||
Assert.Equal(50.0, FfmpegFrameRateParser.ParseFrameRate(out0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_IntegerValue_HasUnitDenominator()
|
||||
{
|
||||
var out0 = "avg_frame_rate=60\n";
|
||||
Assert.Equal(60.0, FfmpegFrameRateParser.ParseFrameRate(out0));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("avg_frame_rate=0/0\nr_frame_rate=0/0\n")]
|
||||
[InlineData("")]
|
||||
[InlineData("codec_name=h264\n")]
|
||||
public void Parse_Unknown_ReturnsNull(string output)
|
||||
{
|
||||
Assert.Null(FfmpegFrameRateParser.ParseFrameRate(output));
|
||||
}
|
||||
|
||||
// ─── The one integration test: probe against fake locator + fake subprocess ───
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_ReturnsParsedFps_FromFakeSubprocess()
|
||||
{
|
||||
var toolsDir = CreateTempDir();
|
||||
try
|
||||
{
|
||||
// ffprobe.exe must exist next to the located ffmpeg for the sibling
|
||||
// derivation to return a usable path.
|
||||
var probeExe = Path.Combine(toolsDir, "ffprobe.exe");
|
||||
File.WriteAllText(probeExe, "ffprobe");
|
||||
|
||||
var fakeOutput = "codec_name=h264\navg_frame_rate=24000/1001\nr_frame_rate=24000/1001\n";
|
||||
var probe = new FfmpegFrameRateProbe(
|
||||
new FakeLocator(toolsDir),
|
||||
() => new FakeProbeProcess(System.Text.Encoding.UTF8.GetBytes(fakeOutput)));
|
||||
|
||||
var fps = await probe.ProbeAsync(@"C:\videos\clip.mp4");
|
||||
Assert.Equal(24000.0 / 1001.0, fps!.Value, 3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(toolsDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateTempDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private sealed class FakeLocator : IFfmpegLocator
|
||||
{
|
||||
private readonly string _dir;
|
||||
public FakeLocator(string dir) => _dir = dir;
|
||||
public Task<string> LocateAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(Path.Combine(_dir, "ffmpeg.exe"));
|
||||
}
|
||||
|
||||
private sealed class FakeProbeProcess : IDecodeProcess
|
||||
{
|
||||
private readonly MemoryStream _stream;
|
||||
public FakeProbeProcess(byte[] bytes) => _stream = new MemoryStream(bytes);
|
||||
public void Start(ProcessStartInfo startInfo) { }
|
||||
public Stream StandardOutput => _stream;
|
||||
public bool HasExited => _stream.Position >= _stream.Length;
|
||||
public int ExitCode => 0;
|
||||
public void Kill() { }
|
||||
public Task WaitForExitAsync(CancellationToken ct = default) => Task.CompletedTask;
|
||||
public void Dispose() => _stream.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user