feat(65): the replay ring — encoded fragments in RAM, capped by bytes

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.
This commit is contained in:
2026-11-10 11:59:02 -08:00
parent 1a6a549e17
commit b93744ec7f
2 changed files with 159 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
namespace ytLive.Services;
/// <summary>
/// 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.
///
/// <para><b>Why encoded bytes and not frames:</b> 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. <b>Why whole fragments:</b> 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.</para>
/// </summary>
public sealed class ClipRing
{
private readonly long _capBytes;
private readonly Queue<byte[]> _fragments = new();
private byte[]? _init;
private long _bytes;
public ClipRing(long capBytes)
{
if (capBytes <= 0) throw new ArgumentOutOfRangeException(nameof(capBytes));
_capBytes = capBytes;
}
/// <summary>The stream's initialisation segment (ftyp + moov). Every replayable file needs it, and
/// it does not rotate — it is written once per encoder start.</summary>
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;
/// <summary>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).</summary>
public byte[] Snapshot()
{
if (IsEmpty) return Array.Empty<byte>();
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;
}
/// <summary>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.</summary>
private void Trim()
{
while (_fragments.Count > 0 && _bytes > _capBytes)
{
_bytes -= _fragments.Dequeue().Length;
}
}
}
+68
View File
@@ -0,0 +1,68 @@
using System;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// TASK 65 slice 1 — the replay ring as pure memory.
///
/// <para>The two rules that matter and are easy to break later: the cap is by <b>bytes</b> (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.</para>
/// </summary>
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
}
}