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();
+ }
+}