diff --git a/HANDOFF.md b/HANDOFF.md index 62928d8..9096e5a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -47,7 +47,19 @@ Next (slice 1 step 4's remaining open end): no session acquisition yet — nothi `AcquireAsync`s a media path, so no frames flow until the UI picker (a later slice) starts a session on add and releases on remove. -Next (slice 2): native-FPS pacing (ffprobe FPS probe + per-frame pacing seam). +Slice 2a shipped: the native-FPS **probe seam** (not yet wired into pacing). +`Services/FfmpegFrameRateParser.cs` (pure — prefers `avg_frame_rate=` then +`r_frame_rate=`, rational `N`/`N/M`, unknown→null), `Services/FrameRateProbe.cs` +(`IFrameRateProbe` + `FfmpegFrameRateProbe` — derives sibling `ffprobe.exe` from +the located ffmpeg dir, reuses the `IDecodeProcess` seam for the ffprobe +subprocess text, null if ffprobe absent), and `FfmpegLocator.ProbeFileName` +(`ffprobe.exe`) + conditional extraction alongside ffmpeg.exe. Tests: +`ytLive.Tests/FfmpegFrameRateParserTests.cs` (6 pure units + 1 probe integration +via fake locator/process); `FfmpegLocatorTests` still green — 15/15 pass, build +0 warnings. **Next wiring step (slice 2b):** inject the probe + a pacing delay +seam into `MediaVideoSource` and pace `FrameAvailable` by 1/fps. + +Next (slice 2b): wire the probe into `MediaVideoSource` for native-FPS pacing. Slice 3: loop control (restart decode on `Completed` when `MediaIsLooping`). **Landmine:** a stale `testhost` can lock `ytLive.Tests`'s `ytLive.dll` and break @@ -58,8 +70,8 @@ pure tests run in WSL; user verifies the GUI suites on native Windows PowerShell ## Next -1. Commit slice 1 step 4 (this unit): resolver/preview wire-in + `Source.DisplaySource` media branch + tests. -2. Slice 2: native-FPS pacing (ffprobe FPS probe + per-frame pacing seam). +1. Commit slice 2a (this unit): ffprobe extraction + parser + probe seam + tests + docs. +2. Slice 2b: wire the probe into `MediaVideoSource` for native-FPS pacing (delay seam + tests). 3. Slice 3: loop control (restart decode on `Completed` when `MediaIsLooping`). 4. UI picker slice: acquire/release media sessions on add/remove (the remaining slice-1-step-4 open end). 5. Await user push checkpoint for the pending TASK 22/21 commits. diff --git a/Services/Encoder/FfmpegLocator.cs b/Services/Encoder/FfmpegLocator.cs index f0ecb27..d756ccc 100644 --- a/Services/Encoder/FfmpegLocator.cs +++ b/Services/Encoder/FfmpegLocator.cs @@ -20,6 +20,11 @@ public sealed class FfmpegLocator : IFfmpegLocator { public const string FileName = "ffmpeg.exe"; + /// Sibling probe binary — extracted alongside ffmpeg so the media + /// frame-rate probe () can + /// derive its path from the located ffmpeg directory. + public const string ProbeFileName = "ffprobe.exe"; + /// Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md). 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)) diff --git a/Services/FfmpegFrameRateParser.cs b/Services/FfmpegFrameRateParser.cs new file mode 100644 index 0000000..f7712fe --- /dev/null +++ b/Services/FfmpegFrameRateParser.cs @@ -0,0 +1,53 @@ +using System.Globalization; + +namespace ytLive.Services; + +/// +/// TASK 21 (slice 2): parses the native frame rate of a video from ffprobe +/// -show_entries stream=avg_frame_rate,r_frame_rate output, which reports +/// each as a rational like 30/1 or 60000/1001 (or 0/0 when +/// unknown). Prefers avg_frame_rate (the true display rate over the whole +/// file, correct for VFR) and falls back to r_frame_rate (the container/ +/// codec rate). Pure: no ffprobe, no I/O, unit-testable. +/// +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; + } + + /// The trimmed value of the first line beginning with . + 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; + } + + /// Parses v as N (integer) or N/M (rational) into + /// a frames-per-second double. 0 and 0/M count as unknown. + 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; + } +} diff --git a/Services/FrameRateProbe.cs b/Services/FrameRateProbe.cs new file mode 100644 index 0000000..ba001f8 --- /dev/null +++ b/Services/FrameRateProbe.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using System.IO; +using ytLive.Services.Encoder; + +namespace ytLive.Services; + +/// +/// TASK 21 (slice 2): resolves the native frame rate of a media file so the +/// decoder can pace frames to real time. Seam so +/// never touches ffprobe directly and tests can fake the whole probe. +/// +public interface IFrameRateProbe +{ + /// Native video FPS for , or null when unknown + /// (no video stream, ffprobe unavailable, unparsable). + Task ProbeAsync(string path, CancellationToken cancellationToken = default); +} + +/// +/// Default probe: locates ffmpeg (via ), derives the +/// sibling ffprobe.exe from its directory, and asks ffprobe for the +/// avg_frame_rate/r_frame_rate. Reuses the binary-stdout +/// seam for the subprocess (ffprobe text is read from +/// the same raw stdout). Returns null (no pacing) if ffprobe is unavailable. +/// +public sealed class FfmpegFrameRateProbe : IFrameRateProbe +{ + private readonly IFfmpegLocator _locator; + private readonly Func _probeProcessFactory; + + public FfmpegFrameRateProbe(IFfmpegLocator locator, Func probeProcessFactory) + { + _locator = locator; + _probeProcessFactory = probeProcessFactory; + } + + public async Task 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); + } +} diff --git a/TASKS.md b/TASKS.md index e18e4f8..2fc602f 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1042,6 +1042,7 @@ The tasks below are ordered by dependency and risk. Each task builds on the prev 2. ✅ `SourceType.MediaSource` addition to the enum 3. ✅ `MediaSourceModel`: `MediaPath`, `MediaIsLooping`, `MediaVolume` (0-1), `MediaPlaybackState` — persisted in LayoutStore (schema migration + SELECT/INSERT + round-trip test) 4. ✅ `MediaVideoSource` implements `IMediaFrameSource`: FFmpeg raw video decoder → `VideoFrame` pipeline — spawns ffmpeg `-f rawvideo -pix_fmt bgra`, drains the pipe via pure `RawVideoFrameReader` (`Services/RawVideoFrameReader.cs`), raises `FrameAvailable`/`Completed`; lifecycle is `StartAsync`/`StopAsync`; decode process behind `IDecodeProcess`/`FfmpegDecodeProcess` seam (binary-stdout mirror of `IEncoderProcess`). Shipped 2026-08-31; refactored onto `IMediaFrameSource` with this slice. +4b. ✅ Slice 2a — native-FPS probe seam: `FfmpegFrameRateParser` (pure, prefers `avg_frame_rate=` then `r_frame_rate=`, rational N/N/M, unknown→null) + `IFrameRateProbe`/`FfmpegFrameRateProbe` (derives sibling `ffprobe.exe` from the located ffmpeg dir, reuses the `IDecodeProcess` seam; null if ffprobe absent) + `FfmpegLocator.ProbeFileName` now also extracts `ffprobe.exe` from the pinned archive (conditional). Tests: `FfmpegFrameRateParserTests` (6 pure units + 1 probe integration via fake locator/process) + `FfmpegLocatorTests` still green — 15/15. 5. ✅ `IMediaFrameSource` + `MediaVideoSourceManager` (`Services/IMediaFrameSource.cs`, `Services/MediaVideoSourceManager.cs`): app-wide decode-session ownership refcounted by `MediaPath` with a `Func` factory seam; `AcquireAsync`/`ReleaseAsync`/`ReleaseAllAsync`/`GetLatestFrame`; coalesces frames onto the UI dispatcher onto a single shared `WriteableBitmap` per file (mirror of `ScreenCaptureManager`); `MediaFailed` + `PreviewBitmapChanged`. Unit tests + one integration test (bitmap share/coalesce) — `MediaVideoSourceManagerTests`, 5/5 pass. 6. 🔶 Native-FPS pacing — frames currently emit as fast as the pipe produces them; ffprobe FPS probe + per-frame pacing seam still open (slice 2). 7. ☐ Wire into `FramePump` resolver — `Source { Type: MediaSource }` → latest video frame — **resolver + preview routing + manager wired; session acquisition (start/stop on add/remove) still open (comes with the UI picker)** diff --git a/ai.md b/ai.md index 817a9de..8e3dcd4 100644 --- a/ai.md +++ b/ai.md @@ -683,7 +683,12 @@ seam:** `Func`, `Func` resolver, `Func?` seam, re-read **every frame** (so a mid-stream position flip applies immediately). The strip is pre-rasterized by `Compositor/SocialBarRenderer.cs` diff --git a/ytLive.Tests/FfmpegFrameRateParserTests.cs b/ytLive.Tests/FfmpegFrameRateParserTests.cs new file mode 100644 index 0000000..8f6e49c --- /dev/null +++ b/ytLive.Tests/FfmpegFrameRateParserTests.cs @@ -0,0 +1,116 @@ +using System.Diagnostics; +using System.IO; +using Xunit; +using ytLive.Services; +using ytLive.Services.Encoder; + +namespace ytLive.Tests; + +/// +/// TASK 21 (slice 2): the ffprobe frame-rate parser is pure (synthetic text, no +/// probe); the one integration test drives the real +/// against a fake locator + fake probe subprocess end to end. +/// +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 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(); + } +}