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 + } +}