/*
* VideoKit
* Copyright © 2026 Yusuf Olokoba. All Rights Reserved.
*/
#nullable enable
namespace VideoKit {
using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Clocks;
///
/// Replay buffer for recording the last several seconds of video.
/// NOTE: This only supports recording video.
/// NOTE: This is not supported on WebGL due to the lack of C# multithreading..
///
public sealed class ReplayBuffer {
#region --Client API--
///
/// Create a replay buffer recorder.
///
/// Recording format.
/// Video width.
/// Video height.
/// Video frame rate.
/// Video duration in seconds.
/// Subdirectory name to save recordings. This will be created if it does not exist.
public ReplayBuffer(
MediaRecorder.Format format,
int width,
int height,
float frameRate,
float duration,
string? prefix = null
) {
this.width = width;
this.height = height;
this.frameRate = frameRate;
this.duration = duration;
this.chunkDurationNs = (duration + 2f) * 1e+9f; // add 2 second padding to each chunk
this.prefix = prefix;
this.clock = new();
this.queue = new();
this.finishSource = new();
this.worker = new Thread(async () => {
foreach (var action in queue.GetConsumingEnumerable())
action();
finishSource.SetResult(true);
});
this.chunkTask = Task.FromResult(null!);
worker.Start();
}
///
/// Append a pixel buffer.
///
/// Pixel buffer.
/// Rotation to apply to the pixel buffer.
public void Append(
PixelBuffer pixelBuffer,
PixelBuffer.Rotation rotation = PixelBuffer.Rotation._0
) {
// Check size
var portrait = rotation == PixelBuffer.Rotation._90 || rotation == PixelBuffer.Rotation._270;
var width = portrait ? pixelBuffer.height : pixelBuffer.width;
var height = portrait ? pixelBuffer.width : pixelBuffer.height;
if (width != this.width || height != this.height)
throw new ArgumentException($"Cannot append pixel buffer with size {width}x{height} to replay buffer with size {this.width}x{this.height}");
// Copy pixel data from the camera buffer to an `RGBA8888` buffer
var packet = new Packet(width, height, timestamp: clock.timestamp);
pixelBuffer.CopyTo(packet.buffer, rotation);
// Post work
queue.Add(() => FlushPacket(packet));
}
///
/// Finish writing and return the replay buffer video.
///
public async Task FinishWriting() {
// Wait until writer thread is done
queue.CompleteAdding();
await finishSource.Task;
// Check
if (recorder == null)
throw new InvalidOperationException(@"Recorder failed to finish writing because no pixel buffers were appended");
// Finish writing
var result = await recorder.FinishWriting();
// Concatenate the previous chunk to the final one if the final one is not long enough
var chunk = await chunkTask;
if (result.duration < duration && chunk != null)
result = await MediaAsset.FromConcatenatingAssets(
new[] { chunk, result },
format: format,
prefix: prefix
);
// Trim the result if longer than duration
if (result.duration > duration)
result = await result.TakeLast(
duration: duration,
format: format,
prefix: prefix
);
// Return
return result;
}
#endregion
#region --Operations--
private readonly MediaRecorder.Format format;
private readonly int width;
private readonly int height;
private readonly float frameRate;
private readonly float duration;
private readonly float chunkDurationNs;
private readonly string? prefix;
private readonly RealtimeClock clock;
private readonly BlockingCollection queue;
private readonly TaskCompletionSource finishSource;
private readonly Thread worker;
private MediaRecorder? recorder;
private ulong recorderIdx;
private Task chunkTask;
private void FlushPacket(in Packet packet) {
// Create a new chunk if we hit duration
var chunkIdx = (ulong)(packet.buffer.timestamp / chunkDurationNs);
if (recorderIdx < chunkIdx && recorder != null) {
chunkTask = recorder.FinishWriting();
recorder = null;
}
// Create recorder
if (recorder == null) {
recorder = MediaRecorder.Create( // CHECK
format: format,
width: width,
height: height,
frameRate: frameRate,
sampleRate: 0,
channelCount: 0,
keyframeInterval: 1,
prefix: prefix
).Result;
recorderIdx = chunkIdx;
}
// Append frame
recorder.Append(packet.buffer);
packet.Dispose();
}
#endregion
#region --Encoder Packets--
///
/// This is basically a wrapper of a `PixelBuffer` along with bits required to save on allocations.
///
private readonly struct Packet : IDisposable {
public readonly byte[] data; // allocated via `ArrayPool`
public readonly GCHandle handle; // pins `data` so GC keeps it fixed
public readonly PixelBuffer buffer; // wraps `data`
///
/// Create a packet which contains a `PixelBuffer` backed by data allocated from a memory pool.
///
/// Pixel buffer width.
/// Pixel buffer height.
/// Pixel buffer timestamp.
public unsafe Packet(int width, int height, long timestamp) {
data = ArrayPool.Shared.Rent(width * height * 4);
handle = GCHandle.Alloc(data, GCHandleType.Pinned);
buffer = new PixelBuffer(
width: width,
height: height,
format: PixelBuffer.Format.RGBA8888,
data: (byte*)handle.AddrOfPinnedObject(),
timestamp: timestamp
);
}
///
/// Dispose the packet.
///
public void Dispose() {
buffer.Dispose();
handle.Free();
ArrayPool.Shared.Return(data);
}
}
#endregion
}
}