Files
LlamaCasty/Services/ClipRing.cs
T
gramps b93744ec7f 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.
2026-11-10 11:59:02 -08:00

92 lines
3.1 KiB
C#

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