From b93744ec7faaa5ec64040abbef8274a934e84ccf Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 10 Nov 2026 11:59:02 -0800 Subject: [PATCH] =?UTF-8?q?feat(65):=20the=20replay=20ring=20=E2=80=94=20e?= =?UTF-8?q?ncoded=20fragments=20in=20RAM,=20capped=20by=20bytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of TASK 65, pure memory, no encoder change yet. A 1080p BGRA frame is 8.3 MB, so fifteen seconds of raw composite is ~7.4 GB — the 15 MB figure in the spec is the *compressed* stream. So the ring holds fragmented-MP4 bytes that ffmpeg will write to a pipe, and a save is init-segment + surviving fragments in order. Two rules the tests pin: the cap is by **bytes** (bitrate moves; seconds do not), and the newest fragments always win, because a clip that ends at the keypress is the entire point. The init segment does not rotate and cannot be reused across encoder starts, so `Clear` drops it too — an MP4 without its moov is noise. Next slice: ffmpeg writes the buffer output to stdout while `-stats` stays on stderr, and the pump feeds this ring. Not done here on purpose — an encoder change is the riskiest edit in the app and deserves its own commit and test. --- Services/ClipRing.cs | 91 +++++++++++++++++++++++++++++++++++ ytLive.Tests/ClipRingTests.cs | 68 ++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 Services/ClipRing.cs create mode 100644 ytLive.Tests/ClipRingTests.cs diff --git a/Services/ClipRing.cs b/Services/ClipRing.cs new file mode 100644 index 0000000..c99656f --- /dev/null +++ b/Services/ClipRing.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; + +namespace ytLive.Services; + +/// +/// TASK 65: the replay buffer's memory. ffmpeg writes fragmented MP4 to a pipe; the app keeps the last +/// N seconds of those fragments here and hands them back whole. +/// +/// Why encoded bytes and not frames: a 1080p BGRA composite frame is 8.3 MB, so fifteen +/// seconds at 60 fps is ~7.4 GB of raw frames. The 15 MB figure is the compressed stream, and only that +/// fits in RAM. Why whole fragments: a fragment is the smallest thing that can be replayed, so the +/// cap is a byte cap and the clip may be slightly shorter than the requested seconds — never corrupt. +/// +public sealed class ClipRing +{ + private readonly long _capBytes; + private readonly Queue _fragments = new(); + private byte[]? _init; + private long _bytes; + + public ClipRing(long capBytes) + { + if (capBytes <= 0) throw new ArgumentOutOfRangeException(nameof(capBytes)); + _capBytes = capBytes; + } + + /// The stream's initialisation segment (ftyp + moov). Every replayable file needs it, and + /// it does not rotate — it is written once per encoder start. + public void SetInit(byte[] init) + { + _init = init ?? throw new ArgumentNullException(nameof(init)); + RecomputeBytes(); + Trim(); + } + + public void Append(byte[] fragment) + { + if (fragment is null || fragment.Length == 0) return; + _fragments.Enqueue(fragment); + _bytes += fragment.Length; + Trim(); + } + + public long Bytes => _bytes; + public int FragmentCount => _fragments.Count; + public bool IsEmpty => _init is null || _fragments.Count == 0; + + /// The bytes of a playable clip, init first. Empty array when nothing has been buffered — + /// the caller must not write a zero-byte file (a failed save is better than a file that won't open). + public byte[] Snapshot() + { + if (IsEmpty) return Array.Empty(); + var total = _init!.Length; + foreach (var fragment in _fragments) total += fragment.Length; + + var output = new byte[total]; + var offset = 0; + Buffer.BlockCopy(_init, 0, output, offset, _init.Length); + offset += _init.Length; + foreach (var fragment in _fragments) + { + Buffer.BlockCopy(fragment, 0, output, offset, fragment.Length); + offset += fragment.Length; + } + return output; + } + + public void Clear() + { + _fragments.Clear(); + _init = null; + _bytes = 0; + } + + private void RecomputeBytes() + { + _bytes = _init?.Length ?? 0; + foreach (var fragment in _fragments) _bytes += fragment.Length; + } + + /// Drop oldest fragments until the ring fits. Newest data always wins: a clip that ends at + /// the moment the creator pressed the key is the whole point. + private void Trim() + { + while (_fragments.Count > 0 && _bytes > _capBytes) + { + _bytes -= _fragments.Dequeue().Length; + } + } +} diff --git a/ytLive.Tests/ClipRingTests.cs b/ytLive.Tests/ClipRingTests.cs new file mode 100644 index 0000000..b777b97 --- /dev/null +++ b/ytLive.Tests/ClipRingTests.cs @@ -0,0 +1,68 @@ +using System; +using Xunit; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 65 slice 1 — the replay ring as pure memory. +/// +/// The two rules that matter and are easy to break later: the cap is by bytes (bitrate moves, +/// seconds do not), and a snapshot is always init-segment-first, because an MP4 without its moov is not a +/// video, it is noise. +/// +public sealed class ClipRingTests +{ + [Fact] + public void Snapshot_IsInitThenFragmentsInOrder() + { + var ring = new ClipRing(1024); + ring.SetInit(new byte[] { 1, 2 }); + ring.Append(new byte[] { 3 }); + ring.Append(new byte[] { 4, 5 }); + + Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, ring.Snapshot()); + Assert.Equal(5, ring.Bytes); + Assert.Equal(2, ring.FragmentCount); + } + + [Fact] + public void TheCapDropsOldestFragments_SoTheClipEndsAtTheKeypress() + { + var ring = new ClipRing(capBytes: 10); + ring.SetInit(new byte[2]); + for (var i = 0; i < 5; i++) ring.Append(new byte[3]); // 2 + 15 = 17 → trims + + Assert.True(ring.Bytes <= 10); + Assert.Equal(2, ring.FragmentCount); // newest two survive + + var snapshot = ring.Snapshot(); + Assert.Equal(2 + 3 + 3, snapshot.Length); + } + + [Fact] + public void NothingBuffered_SnapshotsEmpty_SoTheCallerCannotWriteAZeroByteFile() + { + var ring = new ClipRing(1024); + Assert.True(ring.IsEmpty); + Assert.Empty(ring.Snapshot()); + + ring.SetInit(new byte[4]); + Assert.True(ring.IsEmpty); // init alone is not a clip + Assert.Empty(ring.Snapshot()); + } + + [Fact] + public void ClearForgetsTheInit_SoTheNextEncoderStartCannotReuseAStaleHeader() + { + var ring = new ClipRing(1024); + ring.SetInit(new byte[] { 9 }); + ring.Append(new byte[] { 8 }); + ring.Clear(); + + Assert.True(ring.IsEmpty); + Assert.Equal(0, ring.Bytes); + ring.Append(new byte[] { 7 }); + Assert.Empty(ring.Snapshot()); // still no init → not playable + } +}