From 5577aaaf9152137e8e430006ae7c15f4457c0993 Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 5 Nov 2026 09:57:07 -0800 Subject: [PATCH] =?UTF-8?q?fix(media):=20play=20at=20the=20clip's=20real?= =?UTF-8?q?=20rate=20=E2=80=94=20the=20pipe=20was=20copying=20a=20frame=20?= =?UTF-8?q?per=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "the default video and the green screen video are playing at a hideously slow rate… I thought the playback timing issues were resolved?!" They were not: two independent faults, both in the shared clip pipe. 1. QUADRATIC COPY IN THE FRAME READER. `RawVideoFrameReader` re-concatenated the whole pending buffer on every `Feed`, and the pipe feeds it 64 KB at a time. A 1080p BGRA frame is 8 MB, so one frame cost ~127 copies of a growing buffer — roughly half a gigabyte of memcpy per frame. Measured on a 30 fps clip: 6 frames in 3 seconds (2 fps). The reader now fills one preallocated frame buffer and hands that array to the VideoFrame, so a frame is copied exactly once. Pipe reads are 256 KB. Same clip: 29 fps. 2. PACING SLEPT ON TOP OF THE WORK. A fixed 1/fps sleep after each frame adds the frame's own cost, so 20 ms of work on a 30 fps clip yields 1/(33+20) ≈ 19 fps — measured 20.9. Pacing is now deadline-based (`Helpers/FramePacer.cs`): wait only what is left of the frame's interval, resync when a frame overruns instead of bursting, never sleep longer than one interval. The alert clip shares this pipe, so it gained both fixes. Tests: `FramePacerTests` (subtract work, no drift, late → resync, zero duration, reset); `ChromaKeyRealClipTests.RealClip_PlaysAtItsNativeRate` (throughput floor of 15 fps on the real 30 fps clip — the old code sat at 2); the two pacing assertions now check "at most one interval" rather than an exact sleep, which is what deadline pacing means. Full suite: 574 passed, 0 failed, clean build. --- Helpers/FramePacer.cs | 47 +++++++++++ Services/RawVideoFrameReader.cs | 52 ++++++------ Services/VideoClipPipe.cs | 17 +++- ytLive.Tests/AlertClipDecoderTests.cs | 10 ++- ytLive.Tests/ChromaKeyRealClipTests.cs | 29 +++++++ .../test_greenscreen_moving_subject.mp4 | Bin 23223 -> 0 bytes ytLive.Tests/FramePacerTests.cs | 74 ++++++++++++++++++ ytLive.Tests/MediaVideoSourceTests.cs | 9 ++- 8 files changed, 203 insertions(+), 35 deletions(-) create mode 100644 Helpers/FramePacer.cs delete mode 100644 ytLive.Tests/Fixtures/test_greenscreen_moving_subject.mp4 create mode 100644 ytLive.Tests/FramePacerTests.cs 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 e7e628c791ba55a27c0229e4b939509da701c304..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23223 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 J1cVfZ{|`W$=js3e 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]