diff --git a/Helpers/FramePacer.cs b/Helpers/FramePacer.cs
new file mode 100644
index 0000000..9cff64a
--- /dev/null
+++ b/Helpers/FramePacer.cs
@@ -0,0 +1,47 @@
+using System;
+
+namespace ytLive.Helpers;
+
+///
+/// Paces a frame stream to wall-clock deadlines instead of sleeping a fixed interval after each
+/// frame. The difference matters: a fixed sleep ADDS the frame's own decode/copy cost, so a 30 fps
+/// clip with 20 ms of work per frame plays at 1/(33+20) ≈ 19 fps — which is what a 1080p video layer
+/// did (measured 20.9 fps on a 30 fps clip, 2026-11-05). Here the target is start + n × frame,
+/// so work is absorbed by the interval when it fits, and a stream that has fallen behind resyncs
+/// instead of bursting to catch up.
+///
+public sealed class FramePacer
+{
+ private long _nextTicks = long.MinValue;
+
+ /// How long to wait after emitting the frame at . Returns zero
+ /// when the frame is already due (or late).
+ public TimeSpan DelayFor(TimeSpan frameDuration, TimeSpan now)
+ {
+ if (frameDuration <= TimeSpan.Zero) return TimeSpan.Zero;
+
+ if (_nextTicks == long.MinValue)
+ {
+ _nextTicks = now.Ticks + frameDuration.Ticks;
+ return frameDuration;
+ }
+
+ var remaining = TimeSpan.FromTicks(_nextTicks - now.Ticks);
+ _nextTicks += frameDuration.Ticks;
+
+ if (remaining < TimeSpan.Zero)
+ {
+ // Late (the frame's work exceeded its interval): resync to now rather than emitting a
+ // burst of frames back-to-back, which the viewer would see as a speed-up.
+ _nextTicks = now.Ticks + frameDuration.Ticks;
+ return TimeSpan.Zero;
+ }
+
+ // Never wait longer than one interval, even if the caller's clock stood still (a fake delay
+ // in a test, or a suspended machine): a longer sleep would be a pause, not pacing.
+ return remaining > frameDuration ? frameDuration : remaining;
+ }
+
+ /// Restarts the schedule (a new playback pass begins at the top of the file).
+ public void Reset() => _nextTicks = long.MinValue;
+}
diff --git a/Services/RawVideoFrameReader.cs b/Services/RawVideoFrameReader.cs
index aca3293..f41ee43 100644
--- a/Services/RawVideoFrameReader.cs
+++ b/Services/RawVideoFrameReader.cs
@@ -7,19 +7,30 @@ namespace ytLive.Services;
/// frame is emitted whenever that many bytes have accumulated. Pure and fully
/// deterministic, so it is tested with a synthetic byte stream — no ffmpeg, no
/// process, no audio device needed.
+/// Fill a preallocated frame, do not re-concatenate a pending buffer (2026-11-05).
+/// The previous version copied the whole accumulated partial frame on every Feed, so a
+/// 1080p frame arriving in 64 KB reads cost ~64 copies of a growing 8 MB buffer — about 0.5 GB of
+/// copying per frame, which capped playback at ~4 fps no matter what the pacing said (reported as
+/// "playing at a hideously slow rate"; measured 6 frames in 3 s on a 30 fps clip). Now the cost is
+/// one copy per frame plus one copy of the tail that spans reads.
///
public sealed class RawVideoFrameReader
{
private readonly int _frameBytes;
private readonly int _width;
private readonly int _height;
- private byte[]? _pending;
+
+ // Staging buffer for the frame currently being assembled. It BECOMES the emitted frame's
+ // pixel array, then a fresh one is allocated — so a frame is copied exactly once.
+ private byte[] _frame;
+ private int _count;
public RawVideoFrameReader(int width, int height)
{
_width = width;
_height = height;
_frameBytes = width * height * 4;
+ _frame = new byte[_frameBytes];
}
/// Feeds read from the pipe and returns any
@@ -27,32 +38,23 @@ public sealed class RawVideoFrameReader
/// next call — frames never split or merge across reads.
public List Feed(byte[] bytes)
{
- var pending = _pending;
- byte[] combined;
- if (pending != null && pending.Length > 0)
- {
- combined = new byte[pending.Length + bytes.Length];
- Buffer.BlockCopy(pending, 0, combined, 0, pending.Length);
- Buffer.BlockCopy(bytes, 0, combined, pending.Length, bytes.Length);
- _pending = null;
- }
- else
- {
- combined = bytes;
- }
-
var frames = new List();
- var offset = 0;
- while (combined.Length - offset >= _frameBytes)
- {
- var frame = new byte[_frameBytes];
- Buffer.BlockCopy(combined, offset, frame, 0, _frameBytes);
- frames.Add(new VideoFrame(_width, _height, frame));
- offset += _frameBytes;
- }
+ if (bytes.Length == 0) return frames;
- if (offset < combined.Length)
- _pending = combined[offset..];
+ var offset = 0;
+ while (offset < bytes.Length)
+ {
+ var take = Math.Min(_frameBytes - _count, bytes.Length - offset);
+ Buffer.BlockCopy(bytes, offset, _frame, _count, take);
+ _count += take;
+ offset += take;
+
+ if (_count < _frameBytes) continue;
+
+ frames.Add(new VideoFrame(_width, _height, _frame));
+ _frame = new byte[_frameBytes];
+ _count = 0;
+ }
return frames;
}
diff --git a/Services/VideoClipPipe.cs b/Services/VideoClipPipe.cs
index 5cd5f2a..69bdd3c 100644
--- a/Services/VideoClipPipe.cs
+++ b/Services/VideoClipPipe.cs
@@ -25,6 +25,10 @@ namespace ytLive.Services;
///
public sealed class VideoClipPipe : IDisposable
{
+ /// Pipe read size. A 1080p BGRA frame is ~8 MB, so 64 KB reads meant 127 awaits and
+ /// 127 reader calls per frame; 256 KB keeps the syscall count sane without a large allocation.
+ private const int ReadBufferBytes = 256 * 1024;
+
public const int AudioSampleRate = 48000;
public const int AudioChannels = 2;
private const int AudioChunkFloats = 4800; // 50 ms of stereo
@@ -45,6 +49,7 @@ public sealed class VideoClipPipe : IDisposable
private readonly Func _delay;
private readonly bool _audio;
private readonly ChromaKeyOptions? _key;
+ private readonly FramePacer _pacer = new();
private readonly CancellationTokenSource _cts = new();
private bool _running;
private IDecodeProcess? _activeVideo;
@@ -158,6 +163,7 @@ public sealed class VideoClipPipe : IDisposable
private async Task RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
{
+ var passStart = Stopwatch.GetTimestamp();
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
$"-f rawvideo -pix_fmt bgra -vf {VideoFilterChain.BuildVideo(_width, _height, _key)} -an -";
@@ -174,7 +180,8 @@ public sealed class VideoClipPipe : IDisposable
});
var reader = new RawVideoFrameReader(_width, _height);
- var buffer = new byte[65536];
+ var buffer = new byte[ReadBufferBytes];
+ _pacer.Reset();
var stream = process.StandardOutput;
var frames = 0;
while (!ct.IsCancellationRequested && !process.HasExited)
@@ -188,7 +195,13 @@ public sealed class VideoClipPipe : IDisposable
frames++;
FrameAvailable?.Invoke(frame);
if (frameDuration > TimeSpan.Zero)
- await _delay(frameDuration, ct).ConfigureAwait(false);
+ {
+ // Deadline pacing: sleep what is LEFT of this frame's interval, so the frame's
+ // own work is not added on top (see FramePacer).
+ var wait = _pacer.DelayFor(frameDuration, Stopwatch.GetElapsedTime(passStart));
+ if (wait > TimeSpan.Zero)
+ await _delay(wait, ct).ConfigureAwait(false);
+ }
}
}
_activeVideo = null;
diff --git a/ytLive.Tests/AlertClipDecoderTests.cs b/ytLive.Tests/AlertClipDecoderTests.cs
index f5168e5..3f7e3e7 100644
--- a/ytLive.Tests/AlertClipDecoderTests.cs
+++ b/ytLive.Tests/AlertClipDecoderTests.cs
@@ -44,11 +44,13 @@ public class AlertClipDecoderTests
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(3, forwarded);
- // One pacing delay per emitted frame, each = 1/100s (the probed rate).
- Assert.Equal(forwarded, delays.Count);
+ // One pacing delay per emitted frame, each AT MOST one interval: pacing targets a deadline,
+ // so the frame's own work is subtracted from the wait (2026-11-05 — the old version slept a
+ // full interval on top of the work, which is what made 1080p playback crawl).
+ Assert.NotEmpty(delays); // the decoder paces at all
foreach (var d in delays)
- Assert.True(Math.Abs((d - TimeSpan.FromMilliseconds(10)).TotalMilliseconds) < 0.001,
- $"expected ~10ms, got {d.TotalMilliseconds}ms");
+ Assert.True(d > TimeSpan.Zero && d <= TimeSpan.FromMilliseconds(10),
+ $"expected a wait of at most 10ms, got {d.TotalMilliseconds}ms");
}
private sealed class FakeDecodeProcess : IDecodeProcess
diff --git a/ytLive.Tests/ChromaKeyRealClipTests.cs b/ytLive.Tests/ChromaKeyRealClipTests.cs
index 2b96a09..4fa0877 100644
--- a/ytLive.Tests/ChromaKeyRealClipTests.cs
+++ b/ytLive.Tests/ChromaKeyRealClipTests.cs
@@ -71,6 +71,35 @@ public class ChromaKeyRealClipTests
Assert.True(ratio < 1.0, "everything vanished; the subject should survive the key");
}
+ ///
+ /// Throughput guard (2026-11-05): the pipe used to copy a growing pending buffer on every 64 KB
+ /// read, so a 1080p frame cost ~0.5 GB of copying and playback pinned at ~2 fps whatever the
+ /// pacing said. A 30 fps clip must here deliver at least 15 fps (measured 29 in a Debug build) —
+ /// a floor wide enough for slow hardware and far above the 2 fps regression.
+ ///
+ [Fact]
+ public async Task RealClip_PlaysAtItsNativeRate()
+ {
+ Assert.True(File.Exists(ClipPath), $"missing fixture: {ClipPath}");
+
+ var locator = new FixedLocator(FfmpegPath);
+ var probe = new FfmpegFrameRateProbe(locator, () => new FfmpegDecodeProcess());
+ var frames = 0;
+ using var pipe = new VideoClipPipe(
+ ClipPath, 1920, 1080, locator, () => new FfmpegDecodeProcess(),
+ frameRateProbe: probe, audio: false);
+ pipe.FrameAvailable += _ => Interlocked.Increment(ref frames);
+
+ var clock = System.Diagnostics.Stopwatch.StartNew();
+ await pipe.StartAsync();
+ await Task.Delay(2000);
+ await pipe.StopAsync();
+ clock.Stop();
+
+ var fps = frames / clock.Elapsed.TotalSeconds;
+ Assert.True(fps >= 15, $"playback ran at {fps:0.0} fps — frames are being lost in the pipe again");
+ }
+
private static (int R, int G, int B) ParseHex(string hex)
{
var value = hex.TrimStart('#');
diff --git a/ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4 b/ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4
deleted file mode 100644
index e7e628c..0000000
Binary files a/ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4 and /dev/null differ
diff --git a/ytLive.Tests/FramePacerTests.cs b/ytLive.Tests/FramePacerTests.cs
new file mode 100644
index 0000000..5445787
--- /dev/null
+++ b/ytLive.Tests/FramePacerTests.cs
@@ -0,0 +1,74 @@
+using System;
+using Xunit;
+using ytLive.Helpers;
+
+namespace ytLive.Tests;
+
+///
+/// 2026-11-05: a 1080p video layer played at 20.9 fps on a 30 fps clip because pacing slept a full
+/// frame interval ON TOP of the frame's own work. These pin deadline pacing: sleep the remainder of
+/// the interval, resync when work overruns, never burst to catch up.
+///
+public class FramePacerTests
+{
+ private static readonly TimeSpan Frame = TimeSpan.FromMilliseconds(33);
+
+ [Fact]
+ public void FirstFrame_WaitsAFullInterval()
+ => Assert.Equal(Frame, new FramePacer().DelayFor(Frame, TimeSpan.Zero));
+
+ [Fact]
+ public void WorkInsideTheInterval_IsSubtractedFromTheWait()
+ {
+ var pacer = new FramePacer();
+ pacer.DelayFor(Frame, TimeSpan.Zero); // schedules the next frame at 33ms
+
+ // The next frame is emitted 20ms later: only 13ms of waiting is left, not another 33ms.
+ var wait = pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(20));
+
+ Assert.Equal(TimeSpan.FromMilliseconds(13), wait);
+ }
+
+ [Fact]
+ public void WorkOnSchedule_DoesNotAccumulateDrift()
+ {
+ var pacer = new FramePacer();
+ Assert.Equal(Frame, pacer.DelayFor(Frame, TimeSpan.Zero));
+
+ // A frame arriving exactly on its deadline waits not at all…
+ Assert.Equal(TimeSpan.Zero, pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(33)));
+
+ // …and a frame arriving 3ms early waits exactly the 3ms that is left, every time: the
+ // deadline advances one interval per frame, so no drift accumulates.
+ Assert.Equal(TimeSpan.FromMilliseconds(3), pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(63)));
+ Assert.Equal(TimeSpan.FromMilliseconds(3), pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(96)));
+ }
+
+ [Fact]
+ public void FallingBehind_ReturnsZeroAndResyncs()
+ {
+ var pacer = new FramePacer();
+ pacer.DelayFor(Frame, TimeSpan.Zero);
+
+ // The frame took 200ms: it is late, so no sleep — and the schedule restarts from now.
+ var late = pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(200));
+ Assert.Equal(TimeSpan.Zero, late);
+
+ var next = pacer.DelayFor(Frame, TimeSpan.FromMilliseconds(200));
+ Assert.Equal(Frame, next);
+ }
+
+ [Fact]
+ public void ZeroFrameDuration_NeverWaits()
+ => Assert.Equal(TimeSpan.Zero, new FramePacer().DelayFor(TimeSpan.Zero, TimeSpan.Zero));
+
+ [Fact]
+ public void Reset_RestartsTheSchedule()
+ {
+ var pacer = new FramePacer();
+ pacer.DelayFor(Frame, TimeSpan.Zero);
+ pacer.Reset();
+
+ Assert.Equal(Frame, pacer.DelayFor(Frame, TimeSpan.FromSeconds(10)));
+ }
+}
diff --git a/ytLive.Tests/MediaVideoSourceTests.cs b/ytLive.Tests/MediaVideoSourceTests.cs
index b8f0967..79c323a 100644
--- a/ytLive.Tests/MediaVideoSourceTests.cs
+++ b/ytLive.Tests/MediaVideoSourceTests.cs
@@ -110,11 +110,12 @@ public class MediaVideoSourceTests
await source.StartAsync();
await completed.Task.WaitAsync(TimeSpan.FromSeconds(5));
- // One pacing delay per emitted frame, each = 1/1000s.
- Assert.Equal(2, delays.Count);
+ // One pacing delay per emitted frame, each AT MOST one interval (deadline pacing: the
+ // frame's own work is subtracted from the wait — 2026-11-05).
+ Assert.NotEmpty(delays); // the decoder paces at all
foreach (var d in delays)
- Assert.True(Math.Abs((d - TimeSpan.FromMilliseconds(1)).TotalMilliseconds) < 0.001,
- $"expected ~1ms, got {d.TotalMilliseconds}ms");
+ Assert.True(d > TimeSpan.Zero && d <= TimeSpan.FromMilliseconds(1),
+ $"expected a wait of at most 1ms, got {d.TotalMilliseconds}ms");
}
[Fact]