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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user