diff --git a/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/EndToEnd.cs b/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/EndToEnd.cs
new file mode 100644
index 00000000..65c07d89
--- /dev/null
+++ b/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/EndToEnd.cs
@@ -0,0 +1,246 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Buffers;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using UniEnc;
+
+namespace InstantReplay.DiskBufferTests
+{
+ ///
+ /// Drives the real platform encoder, persists every encoded frame through the disk buffer, reads the buffer back
+ /// from the files alone, and muxes the result into an MP4.
+ ///
+ ///
+ /// This is the check that validates the premise of the feature: an encoded payload written to disk in one pass can
+ /// be muxed in a later pass with nothing but the files and the manifest, which is what crash recovery does. It is
+ /// skipped when the native library for the running platform is not present.
+ ///
+ internal static class EndToEnd
+ {
+ private const int Width = 320;
+ private const int Height = 240;
+ private const int FrameRate = 30;
+ private const int SampleRate = 48000;
+ private const int Channels = 2;
+ private const double Seconds = 2.0;
+
+ public static bool IsSupported()
+ {
+ try
+ {
+ using var system = new EncodingSystem(VideoOptions(), AudioOptions());
+ return true;
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ public static async Task RunAsync(string directory)
+ {
+ var manifestPath = Path.Combine(directory, DiskBufferFormat.ManifestFileName);
+ var manifest = DiskBufferManifest.Create(VideoOptions(), AudioOptions(), "OSXEditor", "test", "test");
+ var manifestBytes = manifest.Write(manifestPath);
+
+ // --- pass one: encode and persist -------------------------------------------------
+ var videoRecords = 0;
+ var audioRecords = 0;
+ var metadataRecords = 0;
+
+ using (var writer = new DiskBufferSegmentWriter(directory, 64 * 1024 * 1024, 0.5, 4 * 1024 * 1024,
+ DiskBufferSyncMode.OperatingSystem))
+ {
+ writer.SetManifestBytes(manifestBytes);
+
+ using var system = new EncodingSystem(VideoOptions(), AudioOptions());
+ using var videoEncoder = system.CreateVideoEncoder();
+ using var audioEncoder = system.CreateAudioEncoder();
+
+ var writerLock = new object();
+
+ async Task DrainVideoAsync()
+ {
+ while (true)
+ {
+ var frame = await videoEncoder.PullFrameAsync();
+ if (frame.Data.IsEmpty) break;
+
+ using (frame)
+ {
+ lock (writerLock)
+ {
+ writer.Write(DiskBufferTrack.Video, frame);
+ if (frame.Kind == UniencSampleKind.Metadata) metadataRecords++;
+ else videoRecords++;
+ }
+ }
+ }
+ }
+
+ async Task DrainAudioAsync()
+ {
+ while (true)
+ {
+ var frame = await audioEncoder.PullFrameAsync();
+ if (frame.Data.IsEmpty) break;
+
+ using (frame)
+ {
+ lock (writerLock)
+ {
+ writer.Write(DiskBufferTrack.Audio, frame);
+ if (frame.Kind == UniencSampleKind.Metadata) metadataRecords++;
+ else audioRecords++;
+ }
+ }
+ }
+ }
+
+ await Task.WhenAll(
+ ProduceVideoAsync(videoEncoder),
+ ProduceAudioAsync(audioEncoder),
+ DrainVideoAsync(),
+ DrainAudioAsync());
+ }
+
+ // --- pass two: recover from the files alone ---------------------------------------
+ if (!DiskBufferManifest.TryRead(manifestPath, out var recovered))
+ return new Result(false, "manifest could not be read back", 0, 0, 0, 0);
+
+ var scan = DiskBufferSegmentReader.Scan(directory);
+ var selection = DiskBufferSegmentReader.BuildSelection(scan, null);
+
+ if (selection.VideoFrames.Length == 0)
+ return new Result(false, "no muxable video frames were recovered", videoRecords, audioRecords,
+ metadataRecords, 0);
+
+ var outputPath = Path.Combine(directory, "recovered.mp4");
+
+ using (var system = new EncodingSystem(recovered.ToVideoOptions(), recovered.ToAudioOptions()))
+ using (var muxer = system.CreateMuxer(outputPath))
+ {
+ await EncodedFrameMuxer.MuxAsync(muxer, selection.VideoFrames, selection.AudioFrames);
+ }
+
+ var size = new FileInfo(outputPath).Length;
+ return new Result(true, null, videoRecords, audioRecords, metadataRecords, size, outputPath);
+ }
+
+ private static async Task ProduceVideoAsync(VideoEncoder encoder)
+ {
+ const int frameBytes = Width * Height * 4;
+ using var pool = new SharedBufferPool(frameBytes * 4);
+
+ var total = (int)(FrameRate * Seconds);
+ for (var i = 0; i < total; i++)
+ {
+ SharedBuffer buffer;
+ while (!pool.TryAlloc(frameBytes, out buffer)) Thread.Yield();
+
+ using (buffer)
+ {
+ // Filled in a separate method: a ref struct local may not stay alive across an await.
+ Fill(buffer, i);
+ await encoder.PushFrameAsync(buffer, Width, Height, (double)i / FrameRate);
+ }
+ }
+
+ encoder.CompleteInput();
+ }
+
+ ///
+ /// Writes a moving gradient, so that successive frames genuinely differ and inter frames are produced.
+ ///
+ private static void Fill(SharedBuffer buffer, int frameIndex)
+ {
+ var span = buffer.Value.UnsafeGetSpan();
+ for (var p = 0; p < span.Length; p += 4)
+ {
+ span[p] = (byte)(p + frameIndex * 7);
+ span[p + 1] = (byte)(frameIndex * 3);
+ span[p + 2] = (byte)(p / 4);
+ span[p + 3] = 255;
+ }
+ }
+
+ private static async Task ProduceAudioAsync(AudioEncoder encoder)
+ {
+ var buffer = ArrayPool.Shared.Rent(1024);
+ try
+ {
+ var totalSamples = (int)Math.Ceiling(Seconds * SampleRate);
+ for (var i = 0; i < totalSamples;)
+ {
+ var remaining = (totalSamples - i) * Channels;
+ var block = buffer.AsMemory(0, Math.Min(buffer.Length, remaining));
+
+ for (var j = 0; j < block.Length / Channels; j++)
+ {
+ var t = (double)(i + j) / SampleRate;
+ var value = (short)(Math.Sin(2.0 * Math.PI * 440.0 * t) * short.MaxValue);
+ for (var c = 0; c < Channels; c++) block.Span[j * Channels + c] = value;
+ }
+
+ await encoder.PushSamplesAsync(block, (ulong)i);
+ i += block.Length / Channels;
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+
+ encoder.CompleteInput();
+ }
+
+ private static VideoEncoderOptions VideoOptions()
+ {
+ return new VideoEncoderOptions
+ {
+ Width = Width,
+ Height = Height,
+ FpsHint = FrameRate,
+ Bitrate = 1000000
+ };
+ }
+
+ private static AudioEncoderOptions AudioOptions()
+ {
+ return new AudioEncoderOptions
+ {
+ SampleRate = SampleRate,
+ Channels = Channels,
+ Bitrate = 128000
+ };
+ }
+
+ internal readonly struct Result
+ {
+ public readonly bool Success;
+ public readonly string Error;
+ public readonly int VideoRecords;
+ public readonly int AudioRecords;
+ public readonly int MetadataRecords;
+ public readonly long OutputBytes;
+ public readonly string OutputPath;
+
+ public Result(bool success, string error, int videoRecords, int audioRecords, int metadataRecords,
+ long outputBytes, string outputPath = null)
+ {
+ Success = success;
+ Error = error;
+ VideoRecords = videoRecords;
+ AudioRecords = audioRecords;
+ MetadataRecords = metadataRecords;
+ OutputBytes = outputBytes;
+ OutputPath = outputPath;
+ }
+ }
+ }
+}
diff --git a/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/InstantReplay.DiskBuffer.Tests.csproj b/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/InstantReplay.DiskBuffer.Tests.csproj
new file mode 100644
index 00000000..088eb8b2
--- /dev/null
+++ b/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/InstantReplay.DiskBuffer.Tests.csproj
@@ -0,0 +1,44 @@
+
+
+
+ Exe
+ net8.0
+
+ LatestMajor
+ disable
+ 9
+ true
+ false
+ InstantReplay.DiskBufferTests
+ InstantReplay.DiskBuffer.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/NativeLibraryResolver.cs b/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/NativeLibraryResolver.cs
new file mode 100644
index 00000000..f7988fc6
--- /dev/null
+++ b/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/NativeLibraryResolver.cs
@@ -0,0 +1,65 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using UniEnc;
+
+namespace InstantReplay.DiskBufferTests
+{
+ ///
+ /// Resolves the native library from the runtime identifier directories the UniEnc package lays out, so that the
+ /// end-to-end check can drive the real encoder and muxer.
+ ///
+ internal static class NativeLibraryResolver
+ {
+ [ModuleInitializer]
+ public static void Initialize()
+ {
+ NativeLibrary.SetDllImportResolver(typeof(EncodingSystem).Assembly, (name, _, _) =>
+ {
+ if (!name.Contains("libunienc_c")) return IntPtr.Zero;
+
+ string extension;
+ string platform;
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ platform = "win";
+ extension = ".dll";
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ platform = "osx";
+ extension = ".dylib";
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ platform = "linux";
+ extension = ".so";
+ }
+ else
+ {
+ return IntPtr.Zero;
+ }
+
+ var architecture = RuntimeInformation.OSArchitecture switch
+ {
+ Architecture.Arm64 => "arm64",
+ Architecture.X64 => "x64",
+ _ => null
+ };
+
+ if (architecture == null) return IntPtr.Zero;
+
+ var path = Path.Combine(AppContext.BaseDirectory, "runtimes", $"{platform}-{architecture}", "native",
+ name + extension);
+
+ return File.Exists(path) ? NativeLibrary.Load(path) : IntPtr.Zero;
+ });
+ }
+ }
+}
diff --git a/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/Program.cs b/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/Program.cs
new file mode 100644
index 00000000..7468dec0
--- /dev/null
+++ b/InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests/Program.cs
@@ -0,0 +1,466 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using InstantReplay;
+using UniEnc;
+
+namespace InstantReplay.DiskBufferTests
+{
+ ///
+ /// Exercises the storage layer of the disk buffer without the Unity Editor. Returns a non-zero exit code when a
+ /// check fails.
+ ///
+ internal static class Program
+ {
+ private static int _failures;
+
+ private static int Main()
+ {
+ Run("record round trip", RecordRoundTrip);
+ Run("torn tail is truncated", TornTailIsTruncated);
+ Run("torn header is truncated", TornHeaderIsTruncated);
+ Run("corrupt payload stops materialization", CorruptPayloadStopsMaterialization);
+ Run("disk usage stays within the hard bound", DiskUsageStaysWithinHardBound);
+ Run("metadata survives segment eviction", MetadataSurvivesEviction);
+ Run("duplicate metadata is stored once", DuplicateMetadataStoredOnce);
+ Run("segments start at key frames", SegmentsStartAtKeyFrames);
+ Run("manifest round trip", ManifestRoundTrip);
+ Run("selection starts at the nearest key frame", SelectionStartsAtNearestKeyFrame);
+ RunEndToEnd();
+
+ Console.WriteLine(_failures == 0
+ ? "\nAll checks passed."
+ : $"\n{_failures} check(s) failed.");
+ return _failures == 0 ? 0 : 1;
+ }
+
+ // ---------------------------------------------------------------- checks
+
+ private static void RecordRoundTrip(string directory)
+ {
+ var payloads = new List();
+
+ using (var writer = CreateWriter(directory))
+ {
+ writer.SetManifestBytes(0);
+ Write(writer, DiskBufferTrack.Video, UniencSampleKind.Metadata, 0.0, Payload(7, 0xAA));
+
+ for (var i = 0; i < 10; i++)
+ {
+ var payload = Payload(100 + i, (byte)i);
+ payloads.Add(payload);
+ Write(writer, DiskBufferTrack.Video,
+ i % 5 == 0 ? UniencSampleKind.Key : UniencSampleKind.Interpolated, i * 0.1, payload);
+ }
+
+ for (var i = 0; i < 4; i++)
+ Write(writer, DiskBufferTrack.Audio, UniencSampleKind.Key, i * 0.25, Payload(32, (byte)(i + 100)));
+ }
+
+ var scan = DiskBufferSegmentReader.Scan(directory);
+
+ AssertEqual(10, scan.VideoSamples.Count, "video sample count");
+ AssertEqual(4, scan.AudioSamples.Count, "audio sample count");
+ AssertEqual(1, scan.VideoMetadata.Count, "video metadata count");
+ AssertEqual(0, scan.AudioMetadata.Count, "audio metadata count");
+ AssertTrue(Math.Abs(scan.LatestVideoTimestamp - 0.9) < 1e-9, "latest video timestamp");
+
+ var frames = DiskBufferSegmentReader.Materialize(scan.VideoSamples);
+ AssertEqual(10, frames.Length, "materialized frame count");
+
+ for (var i = 0; i < frames.Length; i++)
+ {
+ AssertTrue(frames[i].Data.SequenceEqual(payloads[i]), $"payload {i} round trip");
+ AssertTrue(Math.Abs(frames[i].Timestamp - i * 0.1) < 1e-9, $"timestamp {i}");
+ AssertEqual(i % 5 == 0 ? UniencSampleKind.Key : UniencSampleKind.Interpolated, frames[i].Kind,
+ $"kind {i}");
+ }
+
+ foreach (var frame in frames) frame.Dispose();
+ }
+
+ private static void TornTailIsTruncated(string directory)
+ {
+ using (var writer = CreateWriter(directory))
+ {
+ writer.SetManifestBytes(0);
+ for (var i = 0; i < 6; i++)
+ Write(writer, DiskBufferTrack.Video, UniencSampleKind.Key, i * 0.1, Payload(64, (byte)i));
+ }
+
+ var segment = DiskBufferSegmentReader.EnumerateSegmentFiles(directory).Last();
+ var full = new FileInfo(segment).Length;
+
+ // Cut the file in the middle of the payload of the last record.
+ Truncate(segment, full - 20);
+
+ var scan = DiskBufferSegmentReader.Scan(directory);
+ AssertEqual(5, scan.VideoSamples.Count, "records before the tear are recovered");
+
+ var frames = DiskBufferSegmentReader.Materialize(scan.VideoSamples);
+ AssertEqual(5, frames.Length, "torn record is not materialized");
+ foreach (var frame in frames) frame.Dispose();
+ }
+
+ private static void TornHeaderIsTruncated(string directory)
+ {
+ using (var writer = CreateWriter(directory))
+ {
+ writer.SetManifestBytes(0);
+ for (var i = 0; i < 4; i++)
+ Write(writer, DiskBufferTrack.Video, UniencSampleKind.Key, i * 0.1, Payload(64, (byte)i));
+ }
+
+ var segment = DiskBufferSegmentReader.EnumerateSegmentFiles(directory).Last();
+ var full = new FileInfo(segment).Length;
+
+ // Cut the file in the middle of the 20-byte header of the last record.
+ Truncate(segment, full - 64 - 10);
+
+ var scan = DiskBufferSegmentReader.Scan(directory);
+ AssertEqual(3, scan.VideoSamples.Count, "records before the torn header are recovered");
+ }
+
+ private static void CorruptPayloadStopsMaterialization(string directory)
+ {
+ using (var writer = CreateWriter(directory))
+ {
+ writer.SetManifestBytes(0);
+ for (var i = 0; i < 5; i++)
+ Write(writer, DiskBufferTrack.Video, UniencSampleKind.Key, i * 0.1, Payload(64, (byte)i));
+ }
+
+ var scan = DiskBufferSegmentReader.Scan(directory);
+ AssertEqual(5, scan.VideoSamples.Count, "all records scanned");
+
+ // Flip one byte inside the payload of the third record. The header stays intact, so only the checksum can
+ // detect it.
+ var third = scan.VideoSamples[2];
+ using (var stream = new FileStream(third.FilePath, FileMode.Open, FileAccess.ReadWrite))
+ {
+ stream.Position = third.PayloadOffset + 5;
+ var b = stream.ReadByte();
+ stream.Position = third.PayloadOffset + 5;
+ stream.WriteByte((byte)(b ^ 0xFF));
+ }
+
+ var frames = DiskBufferSegmentReader.Materialize(scan.VideoSamples);
+ AssertEqual(2, frames.Length, "materialization stops at the corrupt record");
+ foreach (var frame in frames) frame.Dispose();
+ }
+
+ private static void DiskUsageStaysWithinHardBound(string directory)
+ {
+ const long max = 512 * 1024;
+ const int payloadSize = 8 * 1024;
+
+ using (var writer = new DiskBufferSegmentWriter(directory, max, 0.5, 64 * 1024,
+ DiskBufferSyncMode.OperatingSystem))
+ {
+ writer.SetManifestBytes(0);
+ Write(writer, DiskBufferTrack.Video, UniencSampleKind.Metadata, 0.0, Payload(48, 0x5A));
+
+ // Write far more than the bound allows, so eviction must run many times over.
+ for (var i = 0; i < 400; i++)
+ {
+ Write(writer, DiskBufferTrack.Video, i % 4 == 0 ? UniencSampleKind.Key
+ : UniencSampleKind.Interpolated, i * 0.05, Payload(payloadSize, (byte)i));
+
+ AssertTrue(writer.TotalBytes <= max,
+ $"tracked size {writer.TotalBytes} stays within {max} at write {i}");
+
+ if (i % 25 != 0) continue;
+
+ writer.FlushToOperatingSystem();
+ var actual = DirectorySize(directory);
+ AssertTrue(actual <= max, $"actual size {actual} stays within {max} at write {i}");
+ }
+ }
+
+ var final = DirectorySize(directory);
+ AssertTrue(final <= max, $"final size {final} stays within {max}");
+ AssertTrue(final > max / 4, $"final size {final} still uses a useful part of the budget");
+
+ // The buffer must still hold something muxable after all that eviction.
+ var scan = DiskBufferSegmentReader.Scan(directory);
+ AssertTrue(scan.VideoSamples.Count > 0, "records remain after eviction");
+ AssertTrue(scan.VideoMetadata.Count == 1, "metadata survives eviction");
+ }
+
+ private static void MetadataSurvivesEviction(string directory)
+ {
+ const long max = 256 * 1024;
+ var metadataPayload = Payload(37, 0xC3);
+
+ using (var writer = new DiskBufferSegmentWriter(directory, max, 0.2, 32 * 1024,
+ DiskBufferSyncMode.OperatingSystem))
+ {
+ writer.SetManifestBytes(0);
+ Write(writer, DiskBufferTrack.Video, UniencSampleKind.Metadata, 0.0, metadataPayload);
+ Write(writer, DiskBufferTrack.Audio, UniencSampleKind.Metadata, 0.0, Payload(5, 0x11));
+
+ for (var i = 0; i < 300; i++)
+ Write(writer, DiskBufferTrack.Video, i % 3 == 0 ? UniencSampleKind.Key
+ : UniencSampleKind.Interpolated, i * 0.05, Payload(4 * 1024, (byte)i));
+ }
+
+ var scan = DiskBufferSegmentReader.Scan(directory);
+ AssertEqual(1, scan.VideoMetadata.Count, "video metadata still present");
+ AssertEqual(1, scan.AudioMetadata.Count, "audio metadata still present");
+
+ var frames = DiskBufferSegmentReader.Materialize(scan.VideoMetadata);
+ AssertEqual(1, frames.Length, "video metadata materializes");
+ AssertTrue(frames[0].Data.SequenceEqual(metadataPayload), "video metadata payload is intact");
+ AssertEqual(UniencSampleKind.Metadata, frames[0].Kind, "video metadata kind");
+ foreach (var frame in frames) frame.Dispose();
+ }
+
+ private static void DuplicateMetadataStoredOnce(string directory)
+ {
+ var payload = Payload(24, 0x77);
+
+ using (var writer = CreateWriter(directory))
+ {
+ writer.SetManifestBytes(0);
+ for (var i = 0; i < 50; i++)
+ Write(writer, DiskBufferTrack.Video, UniencSampleKind.Metadata, i * 0.1, payload);
+
+ // A genuinely different configuration is kept alongside the first.
+ Write(writer, DiskBufferTrack.Video, UniencSampleKind.Metadata, 5.0, Payload(24, 0x78));
+ Write(writer, DiskBufferTrack.Video, UniencSampleKind.Key, 0.0, Payload(64, 1));
+ }
+
+ var scan = DiskBufferSegmentReader.Scan(directory);
+ AssertEqual(2, scan.VideoMetadata.Count, "identical metadata is written once");
+ }
+
+ private static void SegmentsStartAtKeyFrames(string directory)
+ {
+ using (var writer = new DiskBufferSegmentWriter(directory, 64 * 1024 * 1024, 0.5, 1024 * 1024,
+ DiskBufferSyncMode.OperatingSystem))
+ {
+ writer.SetManifestBytes(0);
+ for (var i = 0; i < 120; i++)
+ Write(writer, DiskBufferTrack.Video, i % 10 == 0 ? UniencSampleKind.Key
+ : UniencSampleKind.Interpolated, i * 0.1, Payload(512, (byte)i));
+ }
+
+ var segments = DiskBufferSegmentReader.EnumerateSegmentFiles(directory);
+ AssertTrue(segments.Count > 1, "the stream rotated into several segments");
+
+ foreach (var segment in segments)
+ {
+ var scan = DiskBufferSegmentReader.Scan(Path.GetDirectoryName(segment));
+ var first = scan.VideoSamples.First(r => r.FilePath == segment);
+ AssertEqual(UniencSampleKind.Key, first.Kind, $"{Path.GetFileName(segment)} starts at a key frame");
+ }
+ }
+
+ private static void ManifestRoundTrip(string directory)
+ {
+ var manifest = DiskBufferManifest.Create(
+ new VideoEncoderOptions { Width = 1280, Height = 720, FpsHint = 30, Bitrate = 2500000 },
+ new AudioEncoderOptions { SampleRate = 44100, Channels = 2, Bitrate = 128000 },
+ "Android", "2022.3.0f1", "1.0.0-\"quoted\"\\slash");
+
+ var path = Path.Combine(directory, DiskBufferFormat.ManifestFileName);
+ var written = manifest.Write(path);
+ AssertTrue(written > 0, "manifest was written");
+
+ AssertTrue(DiskBufferManifest.TryRead(path, out var parsed), "manifest parses");
+ AssertEqual(DiskBufferFormat.FormatVersion, parsed.FormatVersion, "format version");
+ AssertEqual("Android", parsed.Platform, "platform");
+ AssertEqual("1.0.0-\"quoted\"\\slash", parsed.ApplicationVersion, "escaped application version");
+ AssertEqual(1280u, parsed.ToVideoOptions().Width, "video width");
+ AssertEqual(44100u, parsed.ToAudioOptions().SampleRate, "audio sample rate");
+ AssertTrue(parsed.IsCompatibleWith("Android"), "compatible on the recording platform");
+ AssertTrue(!parsed.IsCompatibleWith("IPhonePlayer"), "incompatible on another platform");
+ AssertTrue(parsed.GetStartedAtUtc() != default, "start time parses");
+
+ AssertTrue(!DiskBufferManifest.TryParse("{ \"videoOptions\": { \"width\": 1 } }", out _),
+ "a nested document is rejected rather than partially accepted");
+ }
+
+ private static void SelectionStartsAtNearestKeyFrame(string directory)
+ {
+ _ = directory;
+
+ var video = new EncodedFrameDescriptor[20];
+ for (var i = 0; i < video.Length; i++)
+ video[i] = new EncodedFrameDescriptor(i * 0.5,
+ i % 4 == 0 ? UniencSampleKind.Key : UniencSampleKind.Interpolated);
+
+ var audio = new EncodedFrameDescriptor[20];
+ for (var i = 0; i < audio.Length; i++)
+ audio[i] = new EncodedFrameDescriptor(i * 0.5, UniencSampleKind.Key);
+
+ // Latest video timestamp is 9.5; asking for 4 seconds should start near 5.5, whose nearest key frame is
+ // index 12 at t=6.0.
+ AssertTrue(EncodedFrameSelector.TrySelect(video, audio, 9.5, 4.0, out var videoStart, out var audioStart),
+ "selection succeeds");
+ AssertEqual(12, videoStart, "video start index");
+ AssertEqual(UniencSampleKind.Key, video[videoStart].Kind, "video starts at a key frame");
+ AssertTrue(audioStart >= 0, "audio start index is valid");
+
+ // Without a key frame nothing can be exported.
+ var noKey = new EncodedFrameDescriptor[3];
+ for (var i = 0; i < noKey.Length; i++)
+ noKey[i] = new EncodedFrameDescriptor(i, UniencSampleKind.Interpolated);
+ AssertTrue(!EncodedFrameSelector.TrySelect(noKey, audio, 2.0, null, out _, out _),
+ "selection fails without a key frame");
+
+ // An empty buffer is not an error either.
+ AssertTrue(!EncodedFrameSelector.TrySelect(Array.Empty(), audio, 0, null,
+ out _, out _), "selection fails on an empty buffer");
+ }
+
+ private static void RunEndToEnd()
+ {
+ const string name = "end to end: encode, persist, recover, mux";
+
+ if (!EndToEnd.IsSupported())
+ {
+ Console.WriteLine($" [skip] {name} (native library unavailable on this platform)");
+ return;
+ }
+
+ var directory = Path.Combine(Path.GetTempPath(),
+ "instantreplay-diskbuffer-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+
+ try
+ {
+ var result = EndToEnd.RunAsync(directory).GetAwaiter().GetResult();
+
+ AssertTrue(result.Success, $"end-to-end run succeeded ({result.Error})");
+ AssertTrue(result.VideoRecords > 10, $"video records were persisted ({result.VideoRecords})");
+ AssertTrue(result.AudioRecords > 10, $"audio records were persisted ({result.AudioRecords})");
+ AssertTrue(result.OutputBytes > 4096, $"recovered MP4 is non-trivial ({result.OutputBytes} bytes)");
+
+ if (result.OutputPath != null && File.Exists(result.OutputPath))
+ {
+ AssertTrue(IsMp4(result.OutputPath), "recovered file carries an MP4 signature");
+
+ var keep = Environment.GetEnvironmentVariable("INSTANTREPLAY_TEST_KEEP_OUTPUT");
+ if (!string.IsNullOrEmpty(keep)) File.Copy(result.OutputPath, keep, true);
+ }
+
+ Console.WriteLine($" video={result.VideoRecords} audio={result.AudioRecords} " +
+ $"metadata={result.MetadataRecords} mp4={result.OutputBytes} bytes");
+ }
+ catch (Exception ex)
+ {
+ _failures++;
+ Console.WriteLine($" [ERROR] {name}: {ex}");
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(directory, true);
+ }
+ catch (Exception)
+ {
+ // not a failure
+ }
+ }
+
+ Console.WriteLine($" [done] {name}");
+ }
+
+ private static bool IsMp4(string path)
+ {
+ var header = new byte[12];
+ using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read))
+ {
+ if (stream.Read(header, 0, header.Length) < header.Length) return false;
+ }
+
+ return header[4] == (byte)'f' && header[5] == (byte)'t' && header[6] == (byte)'y' &&
+ header[7] == (byte)'p';
+ }
+
+ // ---------------------------------------------------------------- helpers
+
+ private static DiskBufferSegmentWriter CreateWriter(string directory)
+ {
+ return new DiskBufferSegmentWriter(directory, 64 * 1024 * 1024, 5.0, 8 * 1024 * 1024,
+ DiskBufferSyncMode.OperatingSystem);
+ }
+
+ private static void Write(DiskBufferSegmentWriter writer, DiskBufferTrack track, UniencSampleKind kind,
+ double timestamp, byte[] payload)
+ {
+ using var frame = EncodedFrame.CreateWithCopy(payload, timestamp, kind);
+ writer.Write(track, frame);
+ }
+
+ private static byte[] Payload(int size, byte seed)
+ {
+ var payload = new byte[size];
+ for (var i = 0; i < size; i++) payload[i] = (byte)(seed + i);
+ return payload;
+ }
+
+ private static void Truncate(string path, long length)
+ {
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Write);
+ stream.SetLength(length);
+ }
+
+ private static long DirectorySize(string directory)
+ {
+ return Directory.GetFiles(directory).Sum(file => new FileInfo(file).Length);
+ }
+
+ private static void Run(string name, Action check)
+ {
+ var directory = Path.Combine(Path.GetTempPath(),
+ "instantreplay-diskbuffer-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+
+ var before = _failures;
+ try
+ {
+ check(directory);
+ }
+ catch (Exception ex)
+ {
+ _failures++;
+ Console.WriteLine($" [ERROR] {name}: {ex}");
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(directory, true);
+ }
+ catch (Exception)
+ {
+ // The check itself is what matters; a leftover temporary directory is not a failure.
+ }
+ }
+
+ Console.WriteLine(_failures == before ? $" [ok] {name}" : $" [FAIL] {name}");
+ }
+
+ private static void AssertTrue(bool condition, string what)
+ {
+ if (condition) return;
+ _failures++;
+ Console.WriteLine($" assertion failed: {what}");
+ }
+
+ private static void AssertEqual(T expected, T actual, string what)
+ {
+ if (EqualityComparer.Default.Equals(expected, actual)) return;
+ _failures++;
+ Console.WriteLine($" assertion failed: {what} (expected {expected}, got {actual})");
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedFrameBuffer.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedFrameBuffer.cs
index 6c98ef7f..7320e669 100644
--- a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedFrameBuffer.cs
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedFrameBuffer.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
+using System.Threading.Tasks;
using UniEnc;
namespace InstantReplay
@@ -12,7 +13,7 @@ namespace InstantReplay
///
/// Circular buffer for encoded frames with memory bounds.
///
- internal class BoundedEncodedFrameBuffer : IDisposable
+ internal class BoundedEncodedFrameBuffer : IEncodedFrameBuffer
{
[ThreadStatic] private static List _tempFrames;
private readonly List _audioMetadata = new();
@@ -105,6 +106,15 @@ public bool TryAddAudioFrame(EncodedFrame frame)
return true;
}
+ ///
+ /// Gets frames for the specified duration, adjusted to start from a keyframe.
+ ///
+ public ValueTask GetFramesForDurationAsync(double? durationSeconds)
+ {
+ GetFramesForDuration(durationSeconds, out var videoFrames, out var audioFrames);
+ return new ValueTask(new EncodedFrameSelection(videoFrames, audioFrames));
+ }
+
///
/// Gets frames for the specified duration, adjusted to start from a keyframe.
///
@@ -133,70 +143,22 @@ public void GetFramesForDuration(double? durationSeconds, out ReadOnlyMemory= minTimespan) continue;
- minTimespan = timespan;
- argMinTimespan = i;
- }
- }
- else
- {
- for (var i = 0; i < unprocessedVideoFrames.Length; i++)
- {
- if (unprocessedVideoFrames.Span[i].Kind != UniencSampleKind.Key) continue;
- argMinTimespan = i;
- break;
- }
- }
- if (argMinTimespan == -1)
+ if (!EncodedFrameSelector.TrySelect(
+ EncodedFrameSelector.ToDescriptors(unprocessedVideoFrames.Span),
+ EncodedFrameSelector.ToDescriptors(unprocessedAudioFrames.Span),
+ latest, durationSeconds, out var argMinTimespan, out var argMinAudioTimespan))
{
- // No keyframe found, return empty arrays
+ // Nothing muxable: either no frame at all, or no keyframe to start from.
+ // The metadata frames were taken out of their lists above, so they are released here.
+ EncodedFrameSelector.DisposeAll(videoMetadata.Span, ILogger.LogExceptionCore);
+ EncodedFrameSelector.DisposeAll(audioMetadata.Span, ILogger.LogExceptionCore);
videoFrames = default;
audioFrames = default;
return;
}
- // find audio start index
- int argMinAudioTimespan;
- if (unprocessedAudioFrames.Length == 0)
- {
- argMinAudioTimespan = 0;
- }
- else
- {
- var actualDuration = latest - unprocessedVideoFrames.Span[argMinTimespan].Timestamp;
- var expectedAudioStartTime = unprocessedAudioFrames.Span[^1].Timestamp - actualDuration;
-
- var minAudioTimespan = double.MaxValue;
- argMinAudioTimespan = -1;
- for (var i = 0; i < unprocessedAudioFrames.Length; i++)
- {
- var timespan = Math.Abs(unprocessedAudioFrames.Span[i].Timestamp - expectedAudioStartTime);
- if (timespan >= minAudioTimespan) continue;
- minAudioTimespan = timespan;
- argMinAudioTimespan = i;
- }
- }
-
// split
var videoFramesSpan = unprocessedVideoFrames[argMinTimespan..];
@@ -205,42 +167,12 @@ public void GetFramesForDuration(double? durationSeconds, out ReadOnlyMemory 0)
- {
- var newVideoFrames = new EncodedFrame[videoFramesSpan.Length + videoMetadata.Length];
- videoMetadata.Span.CopyTo(newVideoFrames);
- videoFramesSpan.Span.CopyTo(newVideoFrames.AsSpan(videoMetadata.Length));
- videoFramesSpan = newVideoFrames.AsMemory();
- }
-
- if (audioMetadata.Length > 0)
- {
- var newAudioFrames = new EncodedFrame[audioFramesSpan.Length + audioMetadata.Length];
- audioMetadata.Span.CopyTo(newAudioFrames);
- audioFramesSpan.Span.CopyTo(newAudioFrames.AsSpan(audioMetadata.Length));
- audioFramesSpan = newAudioFrames.AsMemory();
- }
+ videoFramesSpan = EncodedFrameSelector.PrependMetadata(videoFramesSpan, videoMetadata);
+ audioFramesSpan = EncodedFrameSelector.PrependMetadata(audioFramesSpan, audioMetadata);
videoFrames = videoFramesSpan;
audioFrames = audioFramesSpan;
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferFormat.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferFormat.cs
new file mode 100644
index 00000000..a03558a0
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferFormat.cs
@@ -0,0 +1,228 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using UniEnc;
+
+namespace InstantReplay
+{
+ internal enum DiskBufferTrack : byte
+ {
+ Video = 0,
+ Audio = 1
+ }
+
+ ///
+ /// Layout of the files written by the disk buffer. See docs/disk-buffered-recording.md for the rationale.
+ /// This type intentionally has no dependency on UnityEngine so that the storage layer can be exercised outside the
+ /// Unity Editor.
+ ///
+ internal static class DiskBufferFormat
+ {
+ ///
+ /// Bumped whenever the layout below changes. A session written with a different version is not read back.
+ ///
+ public const int FormatVersion = 2;
+
+ public const int FileHeaderSize = 16;
+ public const int RecordHeaderSize = 20;
+
+ ///
+ /// Segment index stored in the header of the metadata file, which is not part of the segment sequence.
+ ///
+ public const uint MetadataFileIndex = 0xFFFFFFFF;
+
+ ///
+ /// Rejects implausible payload lengths while scanning, so that a torn record is not mistaken for a huge one.
+ ///
+ public const int MaxPayloadLength = 64 * 1024 * 1024;
+
+ ///
+ /// Upper bound of the metadata file. Codec configuration is emitted once per stream on every supported
+ /// platform, so this is only a guard against a platform that reissues it without bound; exceeding it would
+ /// otherwise erode the space reserved for segments.
+ ///
+ public const long MaxMetadataBytes = 1024 * 1024;
+
+ public const string ManifestFileName = "manifest.json";
+ public const string MetadataFileName = "metadata.irb";
+ public const string SegmentFileExtension = ".irb";
+ public const string SegmentFilePrefix = "seg-";
+ public const string SegmentFileSearchPattern = SegmentFilePrefix + "*" + SegmentFileExtension;
+
+ // "IRSG"
+ private const uint Magic = 0x47535249;
+
+ public static string GetSegmentFileName(int index)
+ {
+ return $"{SegmentFilePrefix}{index:D8}{SegmentFileExtension}";
+ }
+
+ ///
+ /// Extracts the segment index from a segment file name, or returns -1 when the name is not one.
+ ///
+ public static int ParseSegmentIndex(string fileName)
+ {
+ if (fileName == null) return -1;
+ if (!fileName.StartsWith(SegmentFilePrefix, StringComparison.Ordinal)) return -1;
+ if (!fileName.EndsWith(SegmentFileExtension, StringComparison.Ordinal)) return -1;
+
+ var digits = fileName.Substring(SegmentFilePrefix.Length,
+ fileName.Length - SegmentFilePrefix.Length - SegmentFileExtension.Length);
+
+ return int.TryParse(digits, out var index) && index >= 0 ? index : -1;
+ }
+
+ public static void WriteFileHeader(byte[] destination, uint index)
+ {
+ if (destination == null) throw new ArgumentNullException(nameof(destination));
+ if (destination.Length < FileHeaderSize)
+ throw new ArgumentException("Destination is too small.", nameof(destination));
+
+ WriteUInt32(destination, 0, Magic);
+ WriteUInt32(destination, 4, unchecked((uint)FormatVersion));
+ WriteUInt32(destination, 8, index);
+ WriteUInt32(destination, 12, 0);
+ }
+
+ public static bool TryReadFileHeader(byte[] source, int length, out uint index)
+ {
+ index = 0;
+ if (source == null || length < FileHeaderSize) return false;
+ if (ReadUInt32(source, 0) != Magic) return false;
+ if (ReadUInt32(source, 4) != unchecked((uint)FormatVersion)) return false;
+ index = ReadUInt32(source, 8);
+ return true;
+ }
+
+ public static void WriteRecordHeader(byte[] destination, int offset, int payloadLength, DiskBufferTrack track,
+ UniencSampleKind kind, double timestamp, uint crc32)
+ {
+ if (destination == null) throw new ArgumentNullException(nameof(destination));
+ if (destination.Length - offset < RecordHeaderSize)
+ throw new ArgumentException("Destination is too small.", nameof(destination));
+
+ WriteUInt32(destination, offset, unchecked((uint)payloadLength));
+ destination[offset + 4] = (byte)track;
+ destination[offset + 5] = (byte)kind;
+ destination[offset + 6] = 0;
+ destination[offset + 7] = 0;
+ WriteUInt64(destination, offset + 8, unchecked((ulong)BitConverter.DoubleToInt64Bits(timestamp)));
+ WriteUInt32(destination, offset + 16, crc32);
+ }
+
+ ///
+ /// Parses a record header. Returns false when the header cannot belong to a complete record, which marks the end
+ /// of the usable part of a file truncated by an abnormal termination.
+ ///
+ public static bool TryReadRecordHeader(byte[] source, int offset, long remainingAfterHeader,
+ out DiskBufferRecordHeader header)
+ {
+ header = default;
+ if (source == null || source.Length - offset < RecordHeaderSize) return false;
+
+ var payloadLength = unchecked((int)ReadUInt32(source, offset));
+ if (payloadLength < 0 || payloadLength > MaxPayloadLength) return false;
+ if (payloadLength > remainingAfterHeader) return false;
+
+ var track = source[offset + 4];
+ var kind = source[offset + 5];
+ if (track > (byte)DiskBufferTrack.Audio) return false;
+ if (kind > (byte)UniencSampleKind.Metadata) return false;
+
+ var timestamp = BitConverter.Int64BitsToDouble(unchecked((long)ReadUInt64(source, offset + 8)));
+ if (double.IsNaN(timestamp) || double.IsInfinity(timestamp)) return false;
+
+ header = new DiskBufferRecordHeader(payloadLength, (DiskBufferTrack)track, (UniencSampleKind)kind,
+ timestamp, ReadUInt32(source, offset + 16));
+ return true;
+ }
+
+ private static void WriteUInt32(byte[] destination, int offset, uint value)
+ {
+ destination[offset] = (byte)value;
+ destination[offset + 1] = (byte)(value >> 8);
+ destination[offset + 2] = (byte)(value >> 16);
+ destination[offset + 3] = (byte)(value >> 24);
+ }
+
+ private static void WriteUInt64(byte[] destination, int offset, ulong value)
+ {
+ WriteUInt32(destination, offset, (uint)value);
+ WriteUInt32(destination, offset + 4, (uint)(value >> 32));
+ }
+
+ private static uint ReadUInt32(byte[] source, int offset)
+ {
+ return source[offset]
+ | ((uint)source[offset + 1] << 8)
+ | ((uint)source[offset + 2] << 16)
+ | ((uint)source[offset + 3] << 24);
+ }
+
+ private static ulong ReadUInt64(byte[] source, int offset)
+ {
+ return ReadUInt32(source, offset) | ((ulong)ReadUInt32(source, offset + 4) << 32);
+ }
+ }
+
+ internal readonly struct DiskBufferRecordHeader
+ {
+ public readonly int PayloadLength;
+ public readonly DiskBufferTrack Track;
+ public readonly UniencSampleKind Kind;
+ public readonly double Timestamp;
+ public readonly uint Crc32;
+
+ public DiskBufferRecordHeader(int payloadLength, DiskBufferTrack track, UniencSampleKind kind, double timestamp,
+ uint crc32)
+ {
+ PayloadLength = payloadLength;
+ Track = track;
+ Kind = kind;
+ Timestamp = timestamp;
+ Crc32 = crc32;
+ }
+ }
+
+ ///
+ /// CRC-32 using the IEEE 802.3 polynomial, used to detect a record torn by an abnormal termination.
+ /// System.IO.Hashing is not available on the runtimes this package targets.
+ ///
+ internal static class Crc32
+ {
+ private const uint Polynomial = 0xEDB88320;
+ private static readonly uint[] Table = CreateTable();
+
+ private static uint[] CreateTable()
+ {
+ var table = new uint[256];
+ for (var i = 0u; i < 256u; i++)
+ {
+ var value = i;
+ for (var bit = 0; bit < 8; bit++)
+ value = (value & 1) != 0 ? (value >> 1) ^ Polynomial : value >> 1;
+ table[i] = value;
+ }
+
+ return table;
+ }
+
+ public static uint Compute(ReadOnlySpan data)
+ {
+ var crc = 0xFFFFFFFFu;
+ foreach (var b in data)
+ crc = (crc >> 8) ^ Table[(byte)(crc ^ b)];
+ return crc ^ 0xFFFFFFFFu;
+ }
+
+ public static uint Compute(byte[] data, int offset, int length)
+ {
+ var crc = 0xFFFFFFFFu;
+ for (var i = 0; i < length; i++)
+ crc = (crc >> 8) ^ Table[(byte)(crc ^ data[offset + i])];
+ return crc ^ 0xFFFFFFFFu;
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferFormat.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferFormat.cs.meta
new file mode 100644
index 00000000..b4b697e0
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferFormat.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bacdedc9c2fef4ffa833dd73f06a5219
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferManifest.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferManifest.cs
new file mode 100644
index 00000000..000965bc
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferManifest.cs
@@ -0,0 +1,379 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using UniEnc;
+
+namespace InstantReplay
+{
+ ///
+ /// Contents of the manifest written alongside the buffer files. It records everything a later process needs in
+ /// order to build a muxer that accepts the payloads persisted next to it.
+ ///
+ ///
+ /// The manifest is serialized by hand rather than with UnityEngine.JsonUtility, so that the storage layer can be
+ /// exercised outside the Unity Editor. The document is a flat object of strings and numbers.
+ ///
+ internal sealed class DiskBufferManifest
+ {
+ public int FormatVersion { get; set; }
+ public string StartedAtUtc { get; set; }
+ public string Platform { get; set; }
+ public string UnityVersion { get; set; }
+ public string ApplicationVersion { get; set; }
+
+ public uint VideoWidth { get; set; }
+ public uint VideoHeight { get; set; }
+ public uint VideoFpsHint { get; set; }
+ public uint VideoBitrate { get; set; }
+
+ public uint AudioSampleRate { get; set; }
+ public uint AudioChannels { get; set; }
+ public uint AudioBitrate { get; set; }
+
+ public static DiskBufferManifest Create(in VideoEncoderOptions video, in AudioEncoderOptions audio,
+ string platform, string unityVersion, string applicationVersion)
+ {
+ return new DiskBufferManifest
+ {
+ FormatVersion = DiskBufferFormat.FormatVersion,
+ StartedAtUtc = DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture),
+ Platform = platform ?? string.Empty,
+ UnityVersion = unityVersion ?? string.Empty,
+ ApplicationVersion = applicationVersion ?? string.Empty,
+ VideoWidth = video.Width,
+ VideoHeight = video.Height,
+ VideoFpsHint = video.FpsHint,
+ VideoBitrate = video.Bitrate,
+ AudioSampleRate = audio.SampleRate,
+ AudioChannels = audio.Channels,
+ AudioBitrate = audio.Bitrate
+ };
+ }
+
+ public VideoEncoderOptions ToVideoOptions()
+ {
+ return new VideoEncoderOptions
+ {
+ Width = VideoWidth,
+ Height = VideoHeight,
+ FpsHint = VideoFpsHint,
+ Bitrate = VideoBitrate
+ };
+ }
+
+ public AudioEncoderOptions ToAudioOptions()
+ {
+ return new AudioEncoderOptions
+ {
+ SampleRate = AudioSampleRate,
+ Channels = AudioChannels,
+ Bitrate = AudioBitrate
+ };
+ }
+
+ public DateTime GetStartedAtUtc()
+ {
+ // The round-trip format carries the offset itself, so RoundtripKind must not be combined with
+ // AdjustToUniversal; doing so throws rather than parsing.
+ return DateTime.TryParse(StartedAtUtc, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind,
+ out var value)
+ ? value.ToUniversalTime()
+ : default;
+ }
+
+ ///
+ /// Whether this manifest can be read back by the running build. Matching the format version and the platform is
+ /// necessary but not sufficient: the payloads are serialized by the native library, whose schema may change
+ /// between package versions without any signal observable here. A mismatch of that kind surfaces as a decode
+ /// failure from the muxer, which is why the build identifiers are recorded.
+ ///
+ public bool IsCompatibleWith(string currentPlatform)
+ {
+ return FormatVersion == DiskBufferFormat.FormatVersion &&
+ string.Equals(Platform, currentPlatform, StringComparison.Ordinal);
+ }
+
+ public string ToJson()
+ {
+ var builder = new StringBuilder(512);
+ builder.Append("{\n");
+ AppendNumber(builder, "formatVersion", FormatVersion.ToString(CultureInfo.InvariantCulture), true);
+ AppendString(builder, "startedAtUtc", StartedAtUtc, true);
+ AppendString(builder, "platform", Platform, true);
+ AppendString(builder, "unityVersion", UnityVersion, true);
+ AppendString(builder, "applicationVersion", ApplicationVersion, true);
+ AppendNumber(builder, "videoWidth", VideoWidth.ToString(CultureInfo.InvariantCulture), true);
+ AppendNumber(builder, "videoHeight", VideoHeight.ToString(CultureInfo.InvariantCulture), true);
+ AppendNumber(builder, "videoFpsHint", VideoFpsHint.ToString(CultureInfo.InvariantCulture), true);
+ AppendNumber(builder, "videoBitrate", VideoBitrate.ToString(CultureInfo.InvariantCulture), true);
+ AppendNumber(builder, "audioSampleRate", AudioSampleRate.ToString(CultureInfo.InvariantCulture), true);
+ AppendNumber(builder, "audioChannels", AudioChannels.ToString(CultureInfo.InvariantCulture), true);
+ AppendNumber(builder, "audioBitrate", AudioBitrate.ToString(CultureInfo.InvariantCulture), false);
+ builder.Append("}\n");
+ return builder.ToString();
+ }
+
+ public static bool TryParse(string json, out DiskBufferManifest manifest)
+ {
+ manifest = null;
+ if (!TryParseFlatObject(json, out var values)) return false;
+
+ var parsed = new DiskBufferManifest
+ {
+ StartedAtUtc = GetString(values, "startedAtUtc"),
+ Platform = GetString(values, "platform"),
+ UnityVersion = GetString(values, "unityVersion"),
+ ApplicationVersion = GetString(values, "applicationVersion")
+ };
+
+ if (!TryGetInt(values, "formatVersion", out var formatVersion)) return false;
+ parsed.FormatVersion = formatVersion;
+
+ if (!TryGetUInt(values, "videoWidth", out var videoWidth)) return false;
+ if (!TryGetUInt(values, "videoHeight", out var videoHeight)) return false;
+ if (!TryGetUInt(values, "videoFpsHint", out var videoFpsHint)) return false;
+ if (!TryGetUInt(values, "videoBitrate", out var videoBitrate)) return false;
+ if (!TryGetUInt(values, "audioSampleRate", out var audioSampleRate)) return false;
+ if (!TryGetUInt(values, "audioChannels", out var audioChannels)) return false;
+ if (!TryGetUInt(values, "audioBitrate", out var audioBitrate)) return false;
+
+ parsed.VideoWidth = videoWidth;
+ parsed.VideoHeight = videoHeight;
+ parsed.VideoFpsHint = videoFpsHint;
+ parsed.VideoBitrate = videoBitrate;
+ parsed.AudioSampleRate = audioSampleRate;
+ parsed.AudioChannels = audioChannels;
+ parsed.AudioBitrate = audioBitrate;
+
+ manifest = parsed;
+ return true;
+ }
+
+ ///
+ /// Writes the manifest and flushes it to the storage device. The buffer cannot be recovered without it, so it is
+ /// never left in the operating system's cache. Returns the number of bytes written.
+ ///
+ public long Write(string path)
+ {
+ var bytes = new UTF8Encoding(false).GetBytes(ToJson());
+
+ using (var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read))
+ {
+ stream.Write(bytes, 0, bytes.Length);
+ stream.Flush(true);
+ }
+
+ return bytes.Length;
+ }
+
+ public static bool TryRead(string path, out DiskBufferManifest manifest, Action onError = null)
+ {
+ manifest = null;
+
+ try
+ {
+ if (!File.Exists(path)) return false;
+ return TryParse(File.ReadAllText(path, new UTF8Encoding(false)), out manifest);
+ }
+ catch (Exception ex)
+ {
+ onError?.Invoke(ex);
+ return false;
+ }
+ }
+
+ private static string GetString(IReadOnlyDictionary values, string key)
+ {
+ return values.TryGetValue(key, out var value) ? value : string.Empty;
+ }
+
+ private static bool TryGetInt(IReadOnlyDictionary values, string key, out int result)
+ {
+ result = 0;
+ return values.TryGetValue(key, out var value) &&
+ int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out result);
+ }
+
+ private static bool TryGetUInt(IReadOnlyDictionary values, string key, out uint result)
+ {
+ result = 0;
+ return values.TryGetValue(key, out var value) &&
+ uint.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out result);
+ }
+
+ private static void AppendString(StringBuilder builder, string key, string value, bool comma)
+ {
+ builder.Append(" \"").Append(key).Append("\": \"").Append(Escape(value ?? string.Empty)).Append('"');
+ builder.Append(comma ? ",\n" : "\n");
+ }
+
+ private static void AppendNumber(StringBuilder builder, string key, string value, bool comma)
+ {
+ builder.Append(" \"").Append(key).Append("\": ").Append(value);
+ builder.Append(comma ? ",\n" : "\n");
+ }
+
+ private static string Escape(string value)
+ {
+ var builder = new StringBuilder(value.Length + 8);
+ foreach (var c in value)
+ switch (c)
+ {
+ case '"':
+ builder.Append("\\\"");
+ break;
+ case '\\':
+ builder.Append("\\\\");
+ break;
+ case '\n':
+ builder.Append("\\n");
+ break;
+ case '\r':
+ builder.Append("\\r");
+ break;
+ case '\t':
+ builder.Append("\\t");
+ break;
+ default:
+ if (c < 0x20)
+ builder.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
+ else
+ builder.Append(c);
+ break;
+ }
+
+ return builder.ToString();
+ }
+
+ ///
+ /// Parses a flat JSON object of string and number values into key-value pairs. Nested objects and arrays are
+ /// not supported, and their presence makes the parse fail rather than produce a partial result.
+ ///
+ private static bool TryParseFlatObject(string json, out Dictionary values)
+ {
+ values = new Dictionary(StringComparer.Ordinal);
+ if (string.IsNullOrEmpty(json)) return false;
+
+ var i = 0;
+ SkipWhitespace(json, ref i);
+ if (i >= json.Length || json[i] != '{') return false;
+ i++;
+
+ while (true)
+ {
+ SkipWhitespace(json, ref i);
+ if (i >= json.Length) return false;
+
+ if (json[i] == '}') return true;
+
+ if (json[i] == ',')
+ {
+ i++;
+ continue;
+ }
+
+ if (json[i] != '"') return false;
+ if (!TryReadString(json, ref i, out var key)) return false;
+
+ SkipWhitespace(json, ref i);
+ if (i >= json.Length || json[i] != ':') return false;
+ i++;
+ SkipWhitespace(json, ref i);
+ if (i >= json.Length) return false;
+
+ string value;
+ if (json[i] == '"')
+ {
+ if (!TryReadString(json, ref i, out value)) return false;
+ }
+ else
+ {
+ var start = i;
+ while (i < json.Length && json[i] != ',' && json[i] != '}' && !char.IsWhiteSpace(json[i])) i++;
+ if (i == start) return false;
+ value = json.Substring(start, i - start);
+ if (value.IndexOf('{') >= 0 || value.IndexOf('[') >= 0) return false;
+ }
+
+ values[key] = value;
+ }
+ }
+
+ private static void SkipWhitespace(string json, ref int i)
+ {
+ while (i < json.Length && char.IsWhiteSpace(json[i])) i++;
+ }
+
+ private static bool TryReadString(string json, ref int i, out string result)
+ {
+ result = null;
+ if (i >= json.Length || json[i] != '"') return false;
+ i++;
+
+ var builder = new StringBuilder();
+ while (i < json.Length)
+ {
+ var c = json[i++];
+
+ if (c == '"')
+ {
+ result = builder.ToString();
+ return true;
+ }
+
+ if (c != '\\')
+ {
+ builder.Append(c);
+ continue;
+ }
+
+ if (i >= json.Length) return false;
+ var escape = json[i++];
+ switch (escape)
+ {
+ case '"':
+ builder.Append('"');
+ break;
+ case '\\':
+ builder.Append('\\');
+ break;
+ case '/':
+ builder.Append('/');
+ break;
+ case 'b':
+ builder.Append('\b');
+ break;
+ case 'f':
+ builder.Append('\f');
+ break;
+ case 'n':
+ builder.Append('\n');
+ break;
+ case 'r':
+ builder.Append('\r');
+ break;
+ case 't':
+ builder.Append('\t');
+ break;
+ case 'u':
+ if (i + 4 > json.Length) return false;
+ if (!ushort.TryParse(json.Substring(i, 4), NumberStyles.HexNumber,
+ CultureInfo.InvariantCulture, out var code)) return false;
+ builder.Append((char)code);
+ i += 4;
+ break;
+ default:
+ return false;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferManifest.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferManifest.cs.meta
new file mode 100644
index 00000000..ec2658fd
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferManifest.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 40d68895df9aa4f878d66c37853be0c2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferOptions.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferOptions.cs
new file mode 100644
index 00000000..9cd6af09
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferOptions.cs
@@ -0,0 +1,131 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.IO;
+using UnityEngine;
+
+namespace InstantReplay
+{
+ ///
+ /// Configuration of the opt-in disk buffer of .
+ /// Enabling it lowers memory pressure and makes the footage leading up to a crash recoverable, at the cost of
+ /// continuous writes to storage.
+ ///
+ ///
+ /// Because recording continuously to storage shortens the lifespan of flash memory, the disk buffer is disabled by
+ /// default and is intended primarily for development and quality-assurance builds.
+ ///
+ public struct DiskBufferOptions
+ {
+ ///
+ /// Directory that holds the session directories. When null or empty,
+ /// Application.temporaryCachePath/InstantReplay/DiskBuffer is used.
+ ///
+ public string Directory { get; set; }
+
+ ///
+ /// Hard upper bound of the size of one session directory, covering the manifest, the codec configuration file,
+ /// and every segment file.
+ ///
+ ///
+ /// This is a bound, not a target. Space is reserved before each record is written, and the reservation deletes
+ /// as many of the oldest segments as it needs, so the directory never exceeds this size at any instant. When the
+ /// bound cannot be met even after every evictable segment has been deleted, records are dropped rather than
+ /// written. Setting a value smaller than a few times therefore degrades the
+ /// recording rather than the retention.
+ ///
+ public long MaxDiskUsageBytes { get; set; }
+
+ ///
+ /// Target duration of one segment file in seconds. A segment is closed at the first video key frame after the
+ /// target is reached, so that every segment begins at a key frame and discarding one never leaves a partial
+ /// group of pictures behind.
+ ///
+ public double SegmentDuration { get; set; }
+
+ ///
+ /// Upper bound of the size of one segment file. Reaching it closes the segment at the next video key frame even
+ /// when has not elapsed.
+ ///
+ public long MaxSegmentBytes { get; set; }
+
+ ///
+ /// Upper bound of the total payload size waiting in the write queue. Frames arriving while the queue is full are
+ /// dropped rather than blocking the encoder.
+ ///
+ public long MaxPendingWriteBytes { get; set; }
+
+ ///
+ /// Whether the session directory is kept when the session is disposed normally. When false, which is the
+ /// default, the directory is deleted, so that every directory left behind denotes an abnormal termination.
+ ///
+ public bool RetainOnDispose { get; set; }
+
+ ///
+ /// Flush policy. See for the trade-off between durability and wear on flash
+ /// memory.
+ ///
+ public DiskBufferSyncMode SyncMode { get; set; }
+
+ public static ref readonly DiskBufferOptions Default => ref DefaultValue;
+
+ private static readonly DiskBufferOptions DefaultValue =
+ new()
+ {
+ Directory = null,
+ MaxDiskUsageBytes = 256L * 1024 * 1024, // 256 MiB
+ SegmentDuration = 5.0,
+ MaxSegmentBytes = 8L * 1024 * 1024, // 8 MiB
+ MaxPendingWriteBytes = 4L * 1024 * 1024, // 4 MiB
+ RetainOnDispose = false,
+ SyncMode = DiskBufferSyncMode.OperatingSystem
+ };
+
+ ///
+ /// Smallest bound that still leaves room for the manifest, the codec configuration, and a few segments.
+ ///
+ public const long MinimumDiskUsageBytes = 4L * 1024 * 1024;
+
+ ///
+ /// Returns the configured root directory, or the default one when none was specified.
+ ///
+ public string ResolveDirectory()
+ {
+ return string.IsNullOrEmpty(Directory) ? GetDefaultDirectory() : Directory;
+ }
+
+ ///
+ /// Directory used when is not specified. It is writable on every supported platform,
+ /// is excluded from backup on iOS, and is not visible to the user.
+ ///
+ public static string GetDefaultDirectory()
+ {
+ return Path.Combine(Application.temporaryCachePath, "InstantReplay", "DiskBuffer");
+ }
+
+ internal void Validate()
+ {
+ if (MaxDiskUsageBytes < MinimumDiskUsageBytes)
+ throw new ArgumentOutOfRangeException(nameof(MaxDiskUsageBytes),
+ $"MaxDiskUsageBytes must be at least {MinimumDiskUsageBytes} bytes.");
+
+ if (SegmentDuration <= 0)
+ throw new ArgumentOutOfRangeException(nameof(SegmentDuration),
+ "SegmentDuration must be greater than zero.");
+
+ if (MaxSegmentBytes <= 0)
+ throw new ArgumentOutOfRangeException(nameof(MaxSegmentBytes),
+ "MaxSegmentBytes must be greater than zero.");
+
+ if (MaxSegmentBytes > MaxDiskUsageBytes)
+ throw new ArgumentOutOfRangeException(nameof(MaxSegmentBytes),
+ "MaxSegmentBytes must not exceed MaxDiskUsageBytes.");
+
+ if (MaxPendingWriteBytes <= 0)
+ throw new ArgumentOutOfRangeException(nameof(MaxPendingWriteBytes),
+ "MaxPendingWriteBytes must be greater than zero.");
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferOptions.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferOptions.cs.meta
new file mode 100644
index 00000000..fef904a5
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferOptions.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 26ddcd816f12c46e1851918e152e0ab3
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentReader.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentReader.cs
new file mode 100644
index 00000000..886753e9
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentReader.cs
@@ -0,0 +1,281 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using UniEnc;
+
+namespace InstantReplay
+{
+ ///
+ /// Location and description of one record persisted by .
+ ///
+ internal readonly struct DiskBufferScannedRecord
+ {
+ public readonly string FilePath;
+ public readonly long PayloadOffset;
+ public readonly int PayloadLength;
+ public readonly double Timestamp;
+ public readonly UniencSampleKind Kind;
+ public readonly uint Crc32;
+
+ public DiskBufferScannedRecord(string filePath, long payloadOffset, int payloadLength, double timestamp,
+ UniencSampleKind kind, uint crc32)
+ {
+ FilePath = filePath;
+ PayloadOffset = payloadOffset;
+ PayloadLength = payloadLength;
+ Timestamp = timestamp;
+ Kind = kind;
+ Crc32 = crc32;
+ }
+ }
+
+ ///
+ /// Everything recoverable from one session directory.
+ ///
+ internal sealed class DiskBufferScanResult
+ {
+ public readonly List AudioMetadata = new();
+ public readonly List AudioSamples = new();
+ public readonly List VideoMetadata = new();
+ public readonly List VideoSamples = new();
+
+ ///
+ /// Timestamp of the most recent in-order video sample, which anchors the requested duration.
+ ///
+ public double LatestVideoTimestamp { get; internal set; }
+ }
+
+ ///
+ /// Reads back the files written by .
+ ///
+ ///
+ ///
+ /// The same reader serves the export of a live session and the recovery of a session left behind by an
+ /// abnormal termination, so the recovery path is exercised by every export.
+ ///
+ ///
+ /// Records are appended and never rewritten in place, so a truncation caused by an abnormal termination can
+ /// only occur at the end of a file. Scanning stops at the first record that cannot be complete, and the
+ /// records accepted before that point are returned. This type has no dependency on UnityEngine.
+ ///
+ ///
+ internal static class DiskBufferSegmentReader
+ {
+ ///
+ /// Enumerates the segment files of a session directory in the order they were written.
+ ///
+ public static List EnumerateSegmentFiles(string directory)
+ {
+ var result = new List();
+ if (!Directory.Exists(directory)) return result;
+
+ var indexed = new List>();
+ foreach (var path in Directory.GetFiles(directory, DiskBufferFormat.SegmentFileSearchPattern))
+ {
+ var index = DiskBufferFormat.ParseSegmentIndex(Path.GetFileName(path));
+ if (index < 0) continue;
+ indexed.Add(new KeyValuePair(index, path));
+ }
+
+ indexed.Sort((a, b) => a.Key.CompareTo(b.Key));
+ foreach (var pair in indexed) result.Add(pair.Value);
+ return result;
+ }
+
+ ///
+ /// Scans a session directory. Payload checksums are verified when a payload is read, not here, so that scanning
+ /// does not have to read the whole buffer.
+ ///
+ public static DiskBufferScanResult Scan(string directory, Action onError = null)
+ {
+ var result = new DiskBufferScanResult();
+
+ var metadataPath = Path.Combine(directory, DiskBufferFormat.MetadataFileName);
+ if (File.Exists(metadataPath))
+ ScanFile(metadataPath, result, true, onError);
+
+ foreach (var segment in EnumerateSegmentFiles(directory))
+ ScanFile(segment, result, false, onError);
+
+ double? latest = null;
+ foreach (var record in result.VideoSamples)
+ // MediaCodec may produce an out-of-order frame with timestamp zero at the end of the stream, so the
+ // maximum is taken rather than the last value.
+ if (latest is not { } value || record.Timestamp > value)
+ latest = record.Timestamp;
+
+ result.LatestVideoTimestamp = latest ?? 0;
+ return result;
+ }
+
+ ///
+ /// Builds the muxable selection from a scanned session directory. Used both when a live session exports and
+ /// when a session left behind by an abnormal termination is recovered, so that the two produce the same result.
+ ///
+ ///
+ /// The codec configuration is prepended to both streams, which the muxer requires before any sample. On Apple
+ /// platforms no configuration record is produced, because the parameter sets travel inside every sample; the
+ /// empty case is therefore normal rather than an error.
+ ///
+ public static EncodedFrameSelection BuildSelection(DiskBufferScanResult scan, double? durationSeconds,
+ Action onError = null)
+ {
+ if (scan == null) return default;
+
+ var videoDescriptors = EncodedFrameSelector.ToDescriptors(scan.VideoSamples);
+ var audioDescriptors = EncodedFrameSelector.ToDescriptors(scan.AudioSamples);
+
+ if (!EncodedFrameSelector.TrySelect(videoDescriptors, audioDescriptors, scan.LatestVideoTimestamp,
+ durationSeconds, out var videoStart, out var audioStart))
+ return default;
+
+ var videoRecords = scan.VideoSamples.GetRange(videoStart, scan.VideoSamples.Count - videoStart);
+ var audioRecords = audioStart >= 0 && scan.AudioSamples.Count > 0
+ ? scan.AudioSamples.GetRange(audioStart, scan.AudioSamples.Count - audioStart)
+ : new List();
+
+ var videoFrames = Materialize(videoRecords, onError);
+ var audioFrames = Materialize(audioRecords, onError);
+ var videoMetadata = Materialize(scan.VideoMetadata, onError);
+ var audioMetadata = Materialize(scan.AudioMetadata, onError);
+
+ if (videoFrames.Length == 0)
+ {
+ // Nothing muxable; release everything that was materialized so no pooled array is leaked.
+ EncodedFrameSelector.DisposeAll(videoFrames, onError);
+ EncodedFrameSelector.DisposeAll(audioFrames, onError);
+ EncodedFrameSelector.DisposeAll(videoMetadata, onError);
+ EncodedFrameSelector.DisposeAll(audioMetadata, onError);
+ return default;
+ }
+
+ EncodedFrameSelector.RebaseTimestamps(videoFrames);
+ EncodedFrameSelector.RebaseTimestamps(audioFrames);
+
+ return new EncodedFrameSelection(
+ EncodedFrameSelector.PrependMetadata(videoFrames, videoMetadata),
+ EncodedFrameSelector.PrependMetadata(audioFrames, audioMetadata));
+ }
+
+ ///
+ /// Reads the payloads of the given records and materializes them as frames. Records are grouped by file and
+ /// read in offset order. A record whose checksum does not match truncates the result there, because the
+ /// remainder of the stream cannot be decoded past a corrupt sample.
+ ///
+ public static EncodedFrame[] Materialize(IReadOnlyList records,
+ Action onError = null)
+ {
+ if (records == null || records.Count == 0) return Array.Empty();
+
+ var frames = new List(records.Count);
+ var buffer = Array.Empty();
+ FileStream stream = null;
+ var openPath = (string)null;
+
+ try
+ {
+ foreach (var record in records)
+ {
+ if (!string.Equals(openPath, record.FilePath, StringComparison.Ordinal))
+ {
+ stream?.Dispose();
+ stream = new FileStream(record.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite,
+ 64 * 1024);
+ openPath = record.FilePath;
+ }
+
+ if (buffer.Length < record.PayloadLength) buffer = new byte[record.PayloadLength];
+
+ stream.Position = record.PayloadOffset;
+ if (!ReadExactly(stream, buffer, record.PayloadLength)) break;
+ if (Crc32.Compute(buffer, 0, record.PayloadLength) != record.Crc32) break;
+
+ frames.Add(EncodedFrame.CreateWithCopy(buffer.AsSpan(0, record.PayloadLength), record.Timestamp,
+ record.Kind));
+ }
+ }
+ catch (Exception ex)
+ {
+ onError?.Invoke(ex);
+ }
+ finally
+ {
+ stream?.Dispose();
+ }
+
+ return frames.ToArray();
+ }
+
+ private static void ScanFile(string path, DiskBufferScanResult result, bool metadataFile,
+ Action onError)
+ {
+ try
+ {
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite,
+ 64 * 1024);
+
+ var header = new byte[DiskBufferFormat.FileHeaderSize];
+ if (!ReadExactly(stream, header, header.Length)) return;
+ if (!DiskBufferFormat.TryReadFileHeader(header, header.Length, out _)) return;
+
+ var recordHeader = new byte[DiskBufferFormat.RecordHeaderSize];
+ var length = stream.Length;
+
+ while (true)
+ {
+ var headerPosition = stream.Position;
+ if (length - headerPosition < DiskBufferFormat.RecordHeaderSize) break;
+ if (!ReadExactly(stream, recordHeader, recordHeader.Length)) break;
+
+ var payloadPosition = stream.Position;
+ if (!DiskBufferFormat.TryReadRecordHeader(recordHeader, 0, length - payloadPosition,
+ out var parsed))
+ break;
+
+ var record = new DiskBufferScannedRecord(path, payloadPosition, parsed.PayloadLength,
+ parsed.Timestamp, parsed.Kind, parsed.Crc32);
+
+ if (parsed.Kind == UniencSampleKind.Metadata)
+ {
+ // Codec configuration is expected only in the metadata file. A metadata record found inside a
+ // segment is still honoured so that a buffer written by a future layout is not silently dropped.
+ (parsed.Track == DiskBufferTrack.Video ? result.VideoMetadata : result.AudioMetadata)
+ .Add(record);
+ }
+ else if (metadataFile)
+ {
+ // A sample in the metadata file cannot be placed in the timeline; ignore it rather than guess.
+ }
+ else
+ {
+ (parsed.Track == DiskBufferTrack.Video ? result.VideoSamples : result.AudioSamples)
+ .Add(record);
+ }
+
+ stream.Position = payloadPosition + parsed.PayloadLength;
+ }
+ }
+ catch (Exception ex)
+ {
+ onError?.Invoke(ex);
+ }
+ }
+
+ private static bool ReadExactly(Stream stream, byte[] buffer, int count)
+ {
+ var read = 0;
+ while (read < count)
+ {
+ var n = stream.Read(buffer, read, count - read);
+ if (n <= 0) return false;
+ read += n;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentReader.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentReader.cs.meta
new file mode 100644
index 00000000..81c8829c
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentReader.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 259d15da9529e4ee5817b2b8d5bb970b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentWriter.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentWriter.cs
new file mode 100644
index 00000000..51c241e3
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentWriter.cs
@@ -0,0 +1,390 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using UniEnc;
+
+namespace InstantReplay
+{
+ ///
+ /// Appends encoded frames to the segment files of one session directory, rotates segments at video key frames, and
+ /// deletes the oldest segments so that the session directory never exceeds its configured size.
+ ///
+ ///
+ ///
+ /// The size limit is a hard bound rather than a target. Space for a record is reserved before it is written,
+ /// and the reservation deletes as many of the oldest closed segments as it needs. When the limit still cannot
+ /// be met — which requires the open segment alone to fill the budget — the record is dropped instead of being
+ /// written. The total size of the session directory therefore never exceeds the limit at any instant, not even
+ /// transiently between a write and the eviction that follows it.
+ ///
+ ///
+ /// The limit covers the whole session directory: the manifest, the codec configuration file, and every segment.
+ /// The manifest and the codec configuration file are not evictable, so they are accounted for and the remainder
+ /// is what segments may occupy.
+ ///
+ ///
+ /// Every member is called from the single writer thread of , so no
+ /// synchronization is performed here. This type has no dependency on UnityEngine.
+ ///
+ ///
+ internal sealed class DiskBufferSegmentWriter : IDisposable
+ {
+ private readonly List _closedSegments = new();
+ private readonly string _directory;
+ private readonly long _maxSegmentBytes;
+ private readonly long _maxTotalBytes;
+ private readonly List _metadataAudioPayloads = new();
+ private readonly List _metadataVideoPayloads = new();
+ private readonly Action _onError;
+ private readonly double _segmentDuration;
+ private readonly DiskBufferSyncMode _syncMode;
+
+ private bool _disposed;
+ private long _manifestBytes;
+ private long _metadataBytes;
+ private FileStream _metadataStream;
+ private long _openSegmentBytes;
+ private double? _openSegmentFirstTimestamp;
+ private int _openSegmentIndex = -1;
+ private FileStream _openSegmentStream;
+ private byte[] _scratch = new byte[DiskBufferFormat.RecordHeaderSize + 64 * 1024];
+ private long _totalSegmentBytes;
+
+ public DiskBufferSegmentWriter(string directory, long maxTotalBytes, double segmentDuration,
+ long maxSegmentBytes, DiskBufferSyncMode syncMode, Action onError = null)
+ {
+ _directory = directory ?? throw new ArgumentNullException(nameof(directory));
+ _maxTotalBytes = maxTotalBytes;
+ _segmentDuration = segmentDuration;
+ _maxSegmentBytes = maxSegmentBytes;
+ _syncMode = syncMode;
+ _onError = onError;
+ }
+
+ ///
+ /// Number of records dropped because they did not fit within the configured size.
+ ///
+ public long DroppedRecordCount { get; private set; }
+
+ ///
+ /// Current size of the session directory as tracked by this writer.
+ ///
+ public long TotalBytes => _manifestBytes + _metadataBytes + _totalSegmentBytes;
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ CloseOpenSegment();
+
+ try
+ {
+ _metadataStream?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _onError?.Invoke(ex);
+ }
+
+ _metadataStream = null;
+ }
+
+ ///
+ /// Records the size of the manifest so that it is accounted for against the size limit. The manifest is written
+ /// by the caller before any frame is accepted.
+ ///
+ public void SetManifestBytes(long bytes)
+ {
+ _manifestBytes = bytes;
+ }
+
+ ///
+ /// Writes one frame. Codec configuration goes to the unevictable metadata file; every other frame goes to the
+ /// current segment. Returns false when the frame was dropped because it did not fit.
+ ///
+ public bool Write(DiskBufferTrack track, in EncodedFrame frame)
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(DiskBufferSegmentWriter));
+
+ return frame.Kind == UniencSampleKind.Metadata
+ ? WriteMetadata(track, frame)
+ : WriteSample(track, frame);
+ }
+
+ ///
+ /// Hands everything written so far to the operating system, which is what makes it survive a process crash.
+ ///
+ public void FlushToOperatingSystem()
+ {
+ if (_disposed) return;
+
+ try
+ {
+ _openSegmentStream?.Flush();
+ }
+ catch (Exception ex)
+ {
+ _onError?.Invoke(ex);
+ }
+ }
+
+ private bool WriteMetadata(DiskBufferTrack track, in EncodedFrame frame)
+ {
+ var payload = frame.Data;
+
+ // Codec configuration is emitted once per stream on every supported platform. Storing only distinct payloads
+ // keeps a platform that reissues it from eroding the space reserved for segments.
+ var known = track == DiskBufferTrack.Video ? _metadataVideoPayloads : _metadataAudioPayloads;
+ foreach (var existing in known)
+ if (PayloadEquals(existing, payload))
+ return true;
+
+ var required = DiskBufferFormat.RecordHeaderSize + payload.Length;
+ var fileHeader = _metadataStream == null ? DiskBufferFormat.FileHeaderSize : 0;
+
+ // Losing the codec configuration makes the whole session unrecoverable, so a failure to store it is
+ // reported rather than counted as an ordinary dropped record.
+ if (_metadataBytes + fileHeader + required > DiskBufferFormat.MaxMetadataBytes)
+ {
+ _onError?.Invoke(new InvalidOperationException(
+ "The disk buffer could not store codec configuration: the metadata file is full. " +
+ "The session will not be recoverable."));
+ return false;
+ }
+
+ if (!TryReserve(fileHeader + required))
+ {
+ _onError?.Invoke(new InvalidOperationException(
+ "The disk buffer could not store codec configuration within MaxDiskUsageBytes. " +
+ "The session will not be recoverable; raise MaxDiskUsageBytes."));
+ return false;
+ }
+
+ if (_metadataStream == null)
+ try
+ {
+ _metadataStream = CreateFile(Path.Combine(_directory, DiskBufferFormat.MetadataFileName),
+ DiskBufferFormat.MetadataFileIndex);
+ _metadataBytes += fileHeader;
+ }
+ catch (Exception ex)
+ {
+ _onError?.Invoke(ex);
+ _metadataStream = null;
+ return false;
+ }
+
+ WriteRecord(_metadataStream, track, frame.Kind, frame.Timestamp, payload);
+ _metadataBytes += required;
+
+ var copy = new byte[payload.Length];
+ payload.CopyTo(copy);
+ known.Add(copy);
+
+ // Nothing can be muxed without the codec configuration, so it always reaches the storage device immediately.
+ try
+ {
+ _metadataStream.Flush(true);
+ }
+ catch (Exception ex)
+ {
+ _onError?.Invoke(ex);
+ }
+
+ return true;
+ }
+
+ private bool WriteSample(DiskBufferTrack track, in EncodedFrame frame)
+ {
+ if (ShouldRotate(track, frame)) RotateSegment();
+
+ if (_openSegmentStream == null && !OpenSegment()) return Drop();
+
+ var payload = frame.Data;
+ var required = DiskBufferFormat.RecordHeaderSize + payload.Length;
+
+ if (!TryReserve(required)) return Drop();
+
+ WriteRecord(_openSegmentStream, track, frame.Kind, frame.Timestamp, payload);
+
+ _openSegmentBytes += required;
+ _totalSegmentBytes += required;
+ _openSegmentFirstTimestamp ??= frame.Timestamp;
+
+ if (_syncMode == DiskBufferSyncMode.EveryRecord)
+ try
+ {
+ _openSegmentStream.Flush(true);
+ }
+ catch (Exception ex)
+ {
+ _onError?.Invoke(ex);
+ }
+
+ return true;
+ }
+
+ private bool Drop()
+ {
+ DroppedRecordCount++;
+ return false;
+ }
+
+ private bool ShouldRotate(DiskBufferTrack track, in EncodedFrame frame)
+ {
+ if (_openSegmentStream == null) return false;
+
+ // A segment always starts at a video key frame, so that discarding an older segment never leaves a partial
+ // group of pictures at the head of the buffer.
+ if (track != DiskBufferTrack.Video || frame.Kind != UniencSampleKind.Key) return false;
+
+ if (_openSegmentBytes >= _maxSegmentBytes) return true;
+
+ return _openSegmentFirstTimestamp is { } first && frame.Timestamp - first >= _segmentDuration;
+ }
+
+ private void RotateSegment()
+ {
+ // Closing the open segment first makes it evictable, so that the reservation for the new segment can reclaim
+ // its space. Without this the buffer would deadlock once the open segment alone filled the budget.
+ CloseOpenSegment();
+ OpenSegment();
+ }
+
+ private bool OpenSegment()
+ {
+ if (!TryReserve(DiskBufferFormat.FileHeaderSize)) return false;
+
+ var index = _openSegmentIndex + 1;
+ var path = Path.Combine(_directory, DiskBufferFormat.GetSegmentFileName(index));
+
+ try
+ {
+ _openSegmentStream = CreateFile(path, unchecked((uint)index));
+ }
+ catch (Exception ex)
+ {
+ _onError?.Invoke(ex);
+ _openSegmentStream = null;
+ return false;
+ }
+
+ _openSegmentIndex = index;
+ _openSegmentBytes = DiskBufferFormat.FileHeaderSize;
+ _totalSegmentBytes += DiskBufferFormat.FileHeaderSize;
+ _openSegmentFirstTimestamp = null;
+ return true;
+ }
+
+ private void CloseOpenSegment()
+ {
+ if (_openSegmentStream == null) return;
+
+ var path = _openSegmentStream.Name;
+
+ try
+ {
+ // A closed segment is guaranteed to have reached the storage device, which bounds the loss on power loss
+ // to the records of a single segment.
+ _openSegmentStream.Flush(true);
+ }
+ catch (Exception ex)
+ {
+ _onError?.Invoke(ex);
+ }
+
+ try
+ {
+ _openSegmentStream.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _onError?.Invoke(ex);
+ }
+
+ _openSegmentStream = null;
+ _closedSegments.Add(new ClosedSegment(path, _openSegmentBytes));
+ _openSegmentBytes = 0;
+ _openSegmentFirstTimestamp = null;
+ }
+
+ ///
+ /// Makes room for the given number of bytes by deleting the oldest closed segments. Returns false when the
+ /// limit cannot be met, in which case the caller must not write.
+ ///
+ private bool TryReserve(long additionalBytes)
+ {
+ while (TotalBytes + additionalBytes > _maxTotalBytes)
+ {
+ if (_closedSegments.Count == 0) return false;
+
+ var oldest = _closedSegments[0];
+ _closedSegments.RemoveAt(0);
+ _totalSegmentBytes -= oldest.SizeBytes;
+
+ try
+ {
+ File.Delete(oldest.Path);
+ }
+ catch (Exception ex)
+ {
+ _onError?.Invoke(ex);
+ }
+ }
+
+ return true;
+ }
+
+ private void WriteRecord(FileStream stream, DiskBufferTrack track, UniencSampleKind kind, double timestamp,
+ ReadOnlySpan payload)
+ {
+ var total = DiskBufferFormat.RecordHeaderSize + payload.Length;
+
+ // Grown once and reused for the lifetime of the writer, so that no allocation occurs per frame.
+ if (_scratch.Length < total) _scratch = new byte[total];
+
+ DiskBufferFormat.WriteRecordHeader(_scratch, 0, payload.Length, track, kind, timestamp,
+ Crc32.Compute(payload));
+ payload.CopyTo(_scratch.AsSpan(DiskBufferFormat.RecordHeaderSize));
+
+ stream.Write(_scratch, 0, total);
+ }
+
+ private static bool PayloadEquals(byte[] existing, ReadOnlySpan payload)
+ {
+ if (existing.Length != payload.Length) return false;
+ for (var i = 0; i < existing.Length; i++)
+ if (existing[i] != payload[i])
+ return false;
+ return true;
+ }
+
+ private static FileStream CreateFile(string path, uint index)
+ {
+ var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read);
+
+ var header = new byte[DiskBufferFormat.FileHeaderSize];
+ DiskBufferFormat.WriteFileHeader(header, index);
+ stream.Write(header, 0, header.Length);
+
+ return stream;
+ }
+
+ private readonly struct ClosedSegment
+ {
+ public readonly string Path;
+ public readonly long SizeBytes;
+
+ public ClosedSegment(string path, long sizeBytes)
+ {
+ Path = path;
+ SizeBytes = sizeBytes;
+ }
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentWriter.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentWriter.cs.meta
new file mode 100644
index 00000000..c5f16f03
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSegmentWriter.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2db7de0a8777446c9815fd8de90018f6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSyncMode.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSyncMode.cs
new file mode 100644
index 00000000..67072509
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSyncMode.cs
@@ -0,0 +1,37 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+namespace InstantReplay
+{
+ ///
+ /// Determines how aggressively the disk buffer flushes written data to the storage device.
+ ///
+ ///
+ /// The distinction that matters is between two failure modes. A process crash — a native fault, an out-of-memory
+ /// kill, or an abort — does not lose data that has already reached the operating system through a write, because
+ /// the kernel owns the page cache and flushes it independently of the process. Power loss and a kernel panic lose
+ /// everything that has not been flushed to the storage device.
+ /// Recovering the footage that precedes a crash, which is the purpose of the disk buffer, targets the first mode.
+ /// Defending against it costs no additional device writes, so the default guards against it without shortening the
+ /// lifespan of flash memory. Guarding against the second mode requires a device flush per record, which multiplies
+ /// the number of erase cycles the storage device performs and is therefore not the default.
+ ///
+ public enum DiskBufferSyncMode
+ {
+ ///
+ /// Default. Written data is handed to the operating system after every batch drained from the write queue, and
+ /// flushed to the storage device when a segment is closed, when codec configuration is written, and when the
+ /// manifest is written. Recorded frames survive a process crash. Power loss or a kernel panic loses at most the
+ /// records written since the current segment was opened.
+ ///
+ OperatingSystem = 0,
+
+ ///
+ /// Every record is flushed to the storage device as it is written. Frames survive power loss and a kernel panic,
+ /// at the cost of one device flush per frame. This markedly increases wear on flash memory and is intended for
+ /// diagnosing storage-layer problems rather than for routine use.
+ ///
+ EveryRecord = 1
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSyncMode.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSyncMode.cs.meta
new file mode 100644
index 00000000..cd0a6aba
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskBufferSyncMode.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: f771472cb7e274614a7fe667b1a36559
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBuffer.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBuffer.cs
new file mode 100644
index 00000000..62144f9c
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBuffer.cs
@@ -0,0 +1,273 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using UniEnc;
+using UnityEngine;
+
+namespace InstantReplay
+{
+ ///
+ /// Encoded frame buffer backed by disk storage.
+ /// Frame payloads live in segment files that rotate at video key frames; only the write queue holds them in
+ /// memory. A session left behind by an abnormal termination is recoverable through
+ /// .
+ ///
+ internal sealed class DiskEncodedFrameBuffer : IEncodedFrameBuffer
+ {
+ private static int _sessionCounter;
+
+ private readonly object _lock = new();
+ private readonly long _maxPendingWriteBytes;
+ private readonly Queue _pending = new();
+ private readonly bool _retainOnDispose;
+ private readonly string _sessionDirectory;
+ private readonly Thread _worker;
+ private readonly DiskBufferSegmentWriter _writer;
+
+ private bool _completed;
+ private bool _disposed;
+ private long _droppedFrameCount;
+ private long _pendingBytes;
+ private bool _warnedAboutDrops;
+
+ public DiskEncodedFrameBuffer(in DiskBufferOptions options, in VideoEncoderOptions videoOptions,
+ in AudioEncoderOptions audioOptions)
+ {
+ var validated = options;
+ validated.Validate();
+
+ _retainOnDispose = validated.RetainOnDispose;
+ _maxPendingWriteBytes = validated.MaxPendingWriteBytes;
+
+ var root = validated.ResolveDirectory();
+ _sessionDirectory = Path.Combine(root, CreateSessionId());
+ Directory.CreateDirectory(_sessionDirectory);
+
+ _writer = new DiskBufferSegmentWriter(_sessionDirectory, validated.MaxDiskUsageBytes,
+ validated.SegmentDuration, validated.MaxSegmentBytes, validated.SyncMode, ILogger.LogExceptionCore);
+
+ var manifest = DiskBufferManifest.Create(videoOptions, audioOptions, Application.platform.ToString(),
+ Application.unityVersion, Application.version);
+ _writer.SetManifestBytes(manifest.Write(Path.Combine(_sessionDirectory,
+ DiskBufferFormat.ManifestFileName)));
+
+ _worker = new Thread(RunWorker)
+ {
+ Name = "InstantReplay.DiskBuffer",
+ IsBackground = true
+ };
+ _worker.Start();
+ }
+
+ ///
+ /// Directory this session writes to.
+ ///
+ public string SessionDirectory => _sessionDirectory;
+
+ public bool TryAddVideoFrame(EncodedFrame frame)
+ {
+ return TryEnqueue(DiskBufferTrack.Video, frame);
+ }
+
+ public bool TryAddAudioFrame(EncodedFrame frame)
+ {
+ return TryEnqueue(DiskBufferTrack.Audio, frame);
+ }
+
+ public ValueTask GetFramesForDurationAsync(double? durationSeconds)
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(DiskEncodedFrameBuffer));
+
+ // The worker owns the files, so it must have drained and closed them before they can be read back.
+ Quiesce();
+
+ var scan = DiskBufferSegmentReader.Scan(_sessionDirectory, ILogger.LogExceptionCore);
+ var selection = DiskBufferSegmentReader.BuildSelection(scan, durationSeconds, ILogger.LogExceptionCore);
+ return new ValueTask(selection);
+ }
+
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ if (_disposed) return;
+ _disposed = true;
+ }
+
+ Quiesce();
+
+ if (!_retainOnDispose) DeleteSessionDirectory();
+ }
+
+ ///
+ /// Deletes the session directory. Called after a successful export, and when a session that was disposed
+ /// normally is not configured to be retained.
+ ///
+ public void CleanupStorage()
+ {
+ DeleteSessionDirectory();
+ }
+
+ private bool TryEnqueue(DiskBufferTrack track, EncodedFrame frame)
+ {
+ var length = frame.Data.Length;
+
+ lock (_lock)
+ {
+ if (_disposed || _completed) return false;
+
+ if (_pendingBytes + length > _maxPendingWriteBytes)
+ {
+ // Storage cannot keep up. Dropping here rather than blocking keeps the encoder from stalling, which
+ // is the same trade-off DroppingChannelInput makes for raw frames.
+ _droppedFrameCount++;
+ if (!_warnedAboutDrops)
+ {
+ _warnedAboutDrops = true;
+ ILogger.LogWarningCore(
+ "Dropped an encoded frame because the disk buffer write queue is full. " +
+ "Storage is not keeping up with the encoder; the exported video may show artefacts.");
+ }
+
+ return false;
+ }
+
+ _pending.Enqueue(new PendingWrite(track, frame));
+ _pendingBytes += length;
+ Monitor.Pulse(_lock);
+ return true;
+ }
+ }
+
+ private void RunWorker()
+ {
+ while (true)
+ {
+ PendingWrite item;
+
+ lock (_lock)
+ {
+ while (_pending.Count == 0)
+ {
+ if (_completed) return;
+ Monitor.Wait(_lock);
+ }
+
+ item = _pending.Dequeue();
+ _pendingBytes -= item.Frame.Data.Length;
+ }
+
+ try
+ {
+ using (item.Frame)
+ {
+ _writer.Write(item.Track, item.Frame);
+ }
+ }
+ catch (Exception ex)
+ {
+ ILogger.LogExceptionCore(ex);
+ }
+
+ bool idle;
+ lock (_lock)
+ {
+ idle = _pending.Count == 0;
+ }
+
+ // Handing the batch to the operating system is what makes it survive a process crash. It costs a write
+ // syscall and no device flush, so it does not add wear to flash memory.
+ if (idle) _writer.FlushToOperatingSystem();
+ }
+ }
+
+ ///
+ /// Stops accepting frames, drains everything already accepted, and closes the files.
+ ///
+ private void Quiesce()
+ {
+ lock (_lock)
+ {
+ if (_completed) return;
+ _completed = true;
+ Monitor.PulseAll(_lock);
+ }
+
+ try
+ {
+ _worker.Join();
+ }
+ catch (Exception ex)
+ {
+ ILogger.LogExceptionCore(ex);
+ }
+
+ lock (_lock)
+ {
+ // Anything still queued was never written; release it so no pooled array is leaked.
+ while (_pending.Count > 0)
+ try
+ {
+ _pending.Dequeue().Frame.Dispose();
+ }
+ catch (Exception ex)
+ {
+ ILogger.LogExceptionCore(ex);
+ }
+
+ _pendingBytes = 0;
+ }
+
+ try
+ {
+ _writer.Dispose();
+ }
+ catch (Exception ex)
+ {
+ ILogger.LogExceptionCore(ex);
+ }
+
+ var dropped = _droppedFrameCount + _writer.DroppedRecordCount;
+ if (dropped > 0)
+ ILogger.LogWarningCore(
+ $"The disk buffer dropped {dropped} encoded frame(s) during this session.");
+ }
+
+ private void DeleteSessionDirectory()
+ {
+ try
+ {
+ if (Directory.Exists(_sessionDirectory)) Directory.Delete(_sessionDirectory, true);
+ }
+ catch (Exception ex)
+ {
+ ILogger.LogExceptionCore(ex);
+ }
+ }
+
+ private static string CreateSessionId()
+ {
+ var counter = Interlocked.Increment(ref _sessionCounter);
+ return string.Format(CultureInfo.InvariantCulture, "{0:yyyyMMdd_HHmmssfff}_{1:D4}", DateTime.Now, counter);
+ }
+
+ private readonly struct PendingWrite
+ {
+ public readonly DiskBufferTrack Track;
+ public readonly EncodedFrame Frame;
+
+ public PendingWrite(DiskBufferTrack track, EncodedFrame frame)
+ {
+ Track = track;
+ Frame = frame;
+ }
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBuffer.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBuffer.cs.meta
new file mode 100644
index 00000000..0cabb079
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBuffer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 02db9bdd62d4d4e94b68acb29b5002fc
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBufferRecovery.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBufferRecovery.cs
new file mode 100644
index 00000000..2cb94e09
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBufferRecovery.cs
@@ -0,0 +1,216 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using UniEnc;
+using UnityEngine;
+
+namespace InstantReplay
+{
+ ///
+ /// Recovers encoded frames from a session that was not cleaned up, which
+ /// happens when the process terminated abnormally.
+ ///
+ ///
+ /// A session that is disposed normally removes its own directory, so every directory that remains denotes an
+ /// abnormal termination. Recovery never deletes anything on its own: must be called
+ /// explicitly, so that a failed export can be retried and the raw directory can still be retrieved from the device.
+ ///
+ public sealed class DiskEncodedFrameBufferRecovery
+ {
+ private readonly DiskBufferManifest _manifest;
+
+ private DiskEncodedFrameBufferRecovery(string storagePath, DiskBufferManifest manifest)
+ {
+ StoragePath = storagePath;
+ _manifest = manifest;
+ }
+
+ ///
+ /// Directory holding the recovered session.
+ ///
+ public string StoragePath { get; }
+
+ ///
+ /// When the recorded session started, in UTC.
+ ///
+ public DateTime StartedAtUtc => _manifest.GetStartedAtUtc();
+
+ ///
+ /// Platform the session was recorded on, as reported by Application.platform.
+ ///
+ public string Platform => _manifest.Platform;
+
+ ///
+ /// Application version the session was recorded with.
+ ///
+ public string ApplicationVersion => _manifest.ApplicationVersion;
+
+ ///
+ /// Whether the running build can read this session back. Matching the buffer format and the platform is
+ /// necessary but not sufficient, because the payloads are serialized by the native library and its schema may
+ /// change between package versions. A mismatch of that kind surfaces as a failure from
+ /// .
+ ///
+ public bool IsCompatible => _manifest.IsCompatibleWith(Application.platform.ToString());
+
+ ///
+ /// Total size of the recovered session directory in bytes.
+ ///
+ public long SizeBytes
+ {
+ get
+ {
+ try
+ {
+ if (!Directory.Exists(StoragePath)) return 0;
+
+ long total = 0;
+ foreach (var file in Directory.GetFiles(StoragePath))
+ total += new FileInfo(file).Length;
+ return total;
+ }
+ catch (Exception ex)
+ {
+ ILogger.LogExceptionCore(ex);
+ return 0;
+ }
+ }
+ }
+
+ ///
+ /// Attempts to load a single session directory.
+ ///
+ /// Directory of one session, containing the manifest.
+ /// The recovery instance when the session is readable; null otherwise.
+ /// True when a session with at least one video key frame was found.
+ public static bool TryGetRecoverable(string storagePath, out DiskEncodedFrameBufferRecovery recovery)
+ {
+ recovery = null;
+
+ try
+ {
+ if (string.IsNullOrEmpty(storagePath) || !Directory.Exists(storagePath)) return false;
+
+ var manifestPath = Path.Combine(storagePath, DiskBufferFormat.ManifestFileName);
+ if (!DiskBufferManifest.TryRead(manifestPath, out var manifest, ILogger.LogExceptionCore))
+ return false;
+
+ if (manifest.FormatVersion != DiskBufferFormat.FormatVersion) return false;
+
+ var scan = DiskBufferSegmentReader.Scan(storagePath, ILogger.LogExceptionCore);
+
+ var hasKeyframe = false;
+ foreach (var record in scan.VideoSamples)
+ if (record.Kind == UniencSampleKind.Key)
+ {
+ hasKeyframe = true;
+ break;
+ }
+
+ if (!hasKeyframe) return false;
+
+ recovery = new DiskEncodedFrameBufferRecovery(storagePath, manifest);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ ILogger.LogExceptionCore(ex);
+ return false;
+ }
+ }
+
+ ///
+ /// Enumerates every recoverable session below the given root directory. Pass null to search the directory the
+ /// recorder uses by default. Several sessions may be present when the application has terminated abnormally
+ /// more than once; the caller decides which to export and which to delete.
+ ///
+ public static IReadOnlyList FindRecoverable(string rootDirectory = null)
+ {
+ var result = new List();
+
+ try
+ {
+ var root = string.IsNullOrEmpty(rootDirectory)
+ ? DiskBufferOptions.GetDefaultDirectory()
+ : rootDirectory;
+
+ if (!Directory.Exists(root)) return result;
+
+ var directories = Directory.GetDirectories(root);
+ Array.Sort(directories, StringComparer.Ordinal);
+
+ foreach (var directory in directories)
+ if (TryGetRecoverable(directory, out var recovery))
+ result.Add(recovery);
+ }
+ catch (Exception ex)
+ {
+ ILogger.LogExceptionCore(ex);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Exports the recovered data to an MP4 file. The session directory is left in place; call
+ /// when it is no longer needed.
+ ///
+ /// Duration to export in seconds. Null exports from the earliest key frame.
+ /// Output file path. A default path is generated when null.
+ /// Path to the exported video file.
+ public async ValueTask ExportAsync(double? durationSeconds = null, string outputPath = null)
+ {
+ if (!IsCompatible)
+ ILogger.LogWarningCore(
+ $"Recovering a disk buffer recorded on platform '{_manifest.Platform}' with application version " +
+ $"'{_manifest.ApplicationVersion}'. The payload format is defined by the native library, so the " +
+ "export may fail if it differs from the running build.");
+
+ if (string.IsNullOrEmpty(outputPath))
+ {
+ var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ outputPath = Path.Combine(Application.temporaryCachePath,
+ $"InstantReplay_Recovered_{timestamp}.mp4");
+ }
+
+ var directory = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
+ Directory.CreateDirectory(directory);
+
+ var scan = DiskBufferSegmentReader.Scan(StoragePath, ILogger.LogExceptionCore);
+ var selection = DiskBufferSegmentReader.BuildSelection(scan, durationSeconds, ILogger.LogExceptionCore);
+
+ if (selection.VideoFrames.Length == 0)
+ throw new InvalidOperationException("The disk buffer contains no muxable video frames.");
+
+ using var encodingSystem = new EncodingSystem(_manifest.ToVideoOptions(), _manifest.ToAudioOptions());
+ using var muxer = encodingSystem.CreateMuxer(outputPath);
+
+ await EncodedFrameMuxer.MuxAsync(muxer, selection.VideoFrames, selection.AudioFrames)
+ .ConfigureAwait(false);
+
+ return outputPath;
+ }
+
+ ///
+ /// Deletes the recovered session directory. Recovery never does this implicitly, because a session left behind
+ /// by a crash is the only copy of the footage that preceded it.
+ ///
+ public void Delete()
+ {
+ try
+ {
+ if (Directory.Exists(StoragePath)) Directory.Delete(StoragePath, true);
+ }
+ catch (Exception ex)
+ {
+ ILogger.LogExceptionCore(ex);
+ }
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBufferRecovery.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBufferRecovery.cs.meta
new file mode 100644
index 00000000..c28abaca
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/DiskEncodedFrameBufferRecovery.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2c2633ba7311947adbb75947b941817d
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferAudioInput.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferAudioInput.cs
similarity index 75%
rename from Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferAudioInput.cs
rename to Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferAudioInput.cs
index 018109a6..4dd0b157 100644
--- a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferAudioInput.cs
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferAudioInput.cs
@@ -8,11 +8,11 @@
namespace InstantReplay
{
- internal class BoundedEncodedDataBufferAudioInput : IPipelineInput
+ internal class EncodedFrameBufferAudioInput : IPipelineInput
{
- private readonly BoundedEncodedFrameBuffer _buffer;
+ private readonly IEncodedFrameBuffer _buffer;
- internal BoundedEncodedDataBufferAudioInput(BoundedEncodedFrameBuffer buffer)
+ internal EncodedFrameBufferAudioInput(IEncodedFrameBuffer buffer)
{
_buffer = buffer;
}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferAudioInput.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferAudioInput.cs.meta
similarity index 100%
rename from Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferAudioInput.cs.meta
rename to Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferAudioInput.cs.meta
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferVideoInput.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferVideoInput.cs
similarity index 75%
rename from Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferVideoInput.cs
rename to Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferVideoInput.cs
index 709bb1fe..e8ad1ccb 100644
--- a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferVideoInput.cs
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferVideoInput.cs
@@ -8,11 +8,11 @@
namespace InstantReplay
{
- internal class BoundedEncodedDataBufferVideoInput : IPipelineInput
+ internal class EncodedFrameBufferVideoInput : IPipelineInput
{
- private readonly BoundedEncodedFrameBuffer _buffer;
+ private readonly IEncodedFrameBuffer _buffer;
- internal BoundedEncodedDataBufferVideoInput(BoundedEncodedFrameBuffer buffer)
+ internal EncodedFrameBufferVideoInput(IEncodedFrameBuffer buffer)
{
_buffer = buffer;
}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferVideoInput.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferVideoInput.cs.meta
similarity index 100%
rename from Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/BoundedEncodedDataBufferVideoInput.cs.meta
rename to Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameBufferVideoInput.cs.meta
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameMuxer.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameMuxer.cs
new file mode 100644
index 00000000..507b36eb
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameMuxer.cs
@@ -0,0 +1,98 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Threading.Tasks;
+using UniEnc;
+
+namespace InstantReplay
+{
+ ///
+ /// Pushes a selected set of encoded frames into a muxer and completes it.
+ /// Shared by and so that
+ /// both follow the same completion protocol.
+ ///
+ internal static class EncodedFrameMuxer
+ {
+ ///
+ /// Muxes the given frames. Every frame is disposed, whether or not it was accepted by the muxer.
+ ///
+ public static async ValueTask MuxAsync(Muxer muxer, ReadOnlyMemory videoFrames,
+ ReadOnlyMemory audioFrames)
+ {
+ async ValueTask MuxVideoAsync()
+ {
+ Exception exception = null;
+ for (var i = 0; i < videoFrames.Span.Length; i++)
+ {
+ var frame = videoFrames.Span[i];
+ try
+ {
+ using (frame)
+ {
+ if (exception == null)
+ await muxer.PushVideoDataAsync(frame).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+ }
+
+ // Errors in the input to the muxer do not propagate as exceptions in PushVideoDataAsync;
+ // instead, a channel closed error occurs when attempting to input the next frame.
+ // Since the actual muxer error is returned in FinishVideoAsync,
+ // we should call FinishVideoAsync even if PushVideoDataAsync fails.
+ await muxer.FinishVideoAsync().ConfigureAwait(false);
+
+ if (exception != null)
+ throw exception;
+ }
+
+ async ValueTask MuxAudioAsync()
+ {
+ Exception exception = null;
+ for (var i = 0; i < audioFrames.Span.Length; i++)
+ {
+ var frame = audioFrames.Span[i];
+ try
+ {
+ using (frame)
+ {
+ if (exception == null)
+ await muxer.PushAudioDataAsync(frame).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ exception = ex;
+ }
+ }
+
+ // same as video
+ await muxer.FinishAudioAsync().ConfigureAwait(false);
+
+ if (exception != null)
+ throw exception;
+ }
+
+ // Always observe both tasks even if one of them fails, so that the muxer is not
+ // disposed (by the caller) while the other task is still using it.
+ var whenAll = Task.WhenAll(MuxVideoAsync().AsTask(), MuxAudioAsync().AsTask());
+ try
+ {
+ await whenAll.ConfigureAwait(false);
+ }
+ catch (Exception) when (whenAll.Exception is { InnerExceptions: { Count: > 1 } } aggregate)
+ {
+ // Awaiting Task.WhenAll rethrows only the first exception;
+ // rethrow the AggregateException so that all failures are propagated.
+ throw aggregate;
+ }
+
+ await muxer.CompleteAsync().ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameMuxer.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameMuxer.cs.meta
new file mode 100644
index 00000000..d81af62f
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameMuxer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 826839d95bd4441528023cf76fc0d9c5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameSelector.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameSelector.cs
new file mode 100644
index 00000000..b58ba589
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameSelector.cs
@@ -0,0 +1,160 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using UniEnc;
+
+namespace InstantReplay
+{
+ ///
+ /// Timestamp and kind of a buffered frame, without its payload.
+ ///
+ internal readonly struct EncodedFrameDescriptor
+ {
+ public readonly double Timestamp;
+ public readonly UniencSampleKind Kind;
+
+ public EncodedFrameDescriptor(double timestamp, UniencSampleKind kind)
+ {
+ Timestamp = timestamp;
+ Kind = kind;
+ }
+ }
+
+ ///
+ /// Decides which of the buffered frames make up the exported segment. Shared by the in-memory buffer, the
+ /// disk-backed buffer, and crash recovery, so that all three select frames identically.
+ ///
+ internal static class EncodedFrameSelector
+ {
+ ///
+ /// Finds the index of the first video frame and the first audio frame to export.
+ ///
+ /// Video samples in encode order, excluding codec configuration.
+ /// Audio samples in encode order, excluding codec configuration.
+ /// Timestamp of the most recent in-order video sample.
+ /// Requested duration, or null to start at the earliest key frame.
+ /// False when no key frame is available, in which case nothing can be exported.
+ public static bool TrySelect(ReadOnlySpan video,
+ ReadOnlySpan audio, double latestVideoTimestamp, double? durationSeconds,
+ out int videoStartIndex, out int audioStartIndex)
+ {
+ videoStartIndex = -1;
+ audioStartIndex = 0;
+
+ if (video.Length == 0) return false;
+
+ // find keyframe
+ if (durationSeconds is { } durationSecondsValue)
+ {
+ // TODO: binary search
+ var expectedStartTime = latestVideoTimestamp - durationSecondsValue;
+ var minTimespan = double.MaxValue;
+ for (var i = 0; i < video.Length; i++)
+ {
+ if (video[i].Kind != UniencSampleKind.Key) continue;
+ var timespan = Math.Abs(video[i].Timestamp - expectedStartTime);
+ if (timespan >= minTimespan) continue;
+ minTimespan = timespan;
+ videoStartIndex = i;
+ }
+ }
+ else
+ {
+ for (var i = 0; i < video.Length; i++)
+ {
+ if (video[i].Kind != UniencSampleKind.Key) continue;
+ videoStartIndex = i;
+ break;
+ }
+ }
+
+ if (videoStartIndex == -1) return false;
+
+ // find audio start index
+ if (audio.Length == 0)
+ {
+ audioStartIndex = 0;
+ return true;
+ }
+
+ var actualDuration = latestVideoTimestamp - video[videoStartIndex].Timestamp;
+ var expectedAudioStartTime = audio[^1].Timestamp - actualDuration;
+
+ var minAudioTimespan = double.MaxValue;
+ audioStartIndex = -1;
+ for (var i = 0; i < audio.Length; i++)
+ {
+ var timespan = Math.Abs(audio[i].Timestamp - expectedAudioStartTime);
+ if (timespan >= minAudioTimespan) continue;
+ minAudioTimespan = timespan;
+ audioStartIndex = i;
+ }
+
+ return true;
+ }
+
+ public static EncodedFrameDescriptor[] ToDescriptors(ReadOnlySpan frames)
+ {
+ var descriptors = new EncodedFrameDescriptor[frames.Length];
+ for (var i = 0; i < frames.Length; i++)
+ descriptors[i] = new EncodedFrameDescriptor(frames[i].Timestamp, frames[i].Kind);
+ return descriptors;
+ }
+
+ public static EncodedFrameDescriptor[] ToDescriptors(IReadOnlyList records)
+ {
+ var descriptors = new EncodedFrameDescriptor[records.Count];
+ for (var i = 0; i < records.Count; i++)
+ descriptors[i] = new EncodedFrameDescriptor(records[i].Timestamp, records[i].Kind);
+ return descriptors;
+ }
+
+ ///
+ /// Rebases the timestamps of the frames so that the first frame starts at zero.
+ ///
+ public static void RebaseTimestamps(Span frames)
+ {
+ if (frames.IsEmpty) return;
+
+ var startTime = frames[0].Timestamp;
+ for (var i = 0; i < frames.Length; i++)
+ {
+ ref var frame = ref frames[i];
+ frame = frame.WithTimestamp(frame.Timestamp - startTime);
+ }
+ }
+
+ ///
+ /// Returns the frames with the codec configuration prepended, which the muxer requires before any sample.
+ ///
+ public static Memory PrependMetadata(Memory frames,
+ ReadOnlyMemory metadata)
+ {
+ if (metadata.Length == 0) return frames;
+
+ var result = new EncodedFrame[frames.Length + metadata.Length];
+ metadata.Span.CopyTo(result);
+ frames.Span.CopyTo(result.AsSpan(metadata.Length));
+ return result.AsMemory();
+ }
+
+ ///
+ /// Disposes every frame in the span, reporting but not propagating failures.
+ ///
+ public static void DisposeAll(ReadOnlySpan frames, Action onError)
+ {
+ foreach (var frame in frames)
+ try
+ {
+ frame.Dispose();
+ }
+ catch (Exception ex)
+ {
+ onError?.Invoke(ex);
+ }
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameSelector.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameSelector.cs.meta
new file mode 100644
index 00000000..85cfc641
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/EncodedFrameSelector.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0090ee1eb86014ae0b96a39ed3c53786
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/IEncodedFrameBuffer.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/IEncodedFrameBuffer.cs
new file mode 100644
index 00000000..78cd4968
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/IEncodedFrameBuffer.cs
@@ -0,0 +1,51 @@
+// --------------------------------------------------------------
+// Copyright 2025 CyberAgent, Inc.
+// --------------------------------------------------------------
+
+using System;
+using System.Threading.Tasks;
+using UniEnc;
+
+namespace InstantReplay
+{
+ ///
+ /// Common interface for encoded frame buffers.
+ ///
+ internal interface IEncodedFrameBuffer : IDisposable
+ {
+ ///
+ /// Takes ownership of an encoded video frame. Returns false when the frame was not accepted, in which case the
+ /// caller remains responsible for disposing it.
+ ///
+ bool TryAddVideoFrame(EncodedFrame frame);
+
+ ///
+ /// Takes ownership of an encoded audio frame. Returns false when the frame was not accepted, in which case the
+ /// caller remains responsible for disposing it.
+ ///
+ bool TryAddAudioFrame(EncodedFrame frame);
+
+ ///
+ /// Selects the trailing frames covering the specified duration, starting at a key frame. Pass null to select
+ /// everything from the earliest available key frame. The caller owns and must dispose every returned frame.
+ ///
+ ValueTask GetFramesForDurationAsync(double? durationSeconds);
+ }
+
+ ///
+ /// Frames selected for muxing, with timestamps rebased so that each stream starts at zero and the codec
+ /// configuration prepended.
+ ///
+ internal readonly struct EncodedFrameSelection
+ {
+ public readonly ReadOnlyMemory VideoFrames;
+ public readonly ReadOnlyMemory AudioFrames;
+
+ public EncodedFrameSelection(ReadOnlyMemory videoFrames,
+ ReadOnlyMemory audioFrames)
+ {
+ VideoFrames = videoFrames;
+ AudioFrames = audioFrames;
+ }
+ }
+}
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/IEncodedFrameBuffer.cs.meta b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/IEncodedFrameBuffer.cs.meta
new file mode 100644
index 00000000..d08f5692
--- /dev/null
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/IEncodedFrameBuffer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 72db17cb2956b468785c3797e5f67861
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/RealtimeEncodingOptions.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/RealtimeEncodingOptions.cs
index 9fdbed1b..8515ed9c 100644
--- a/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/RealtimeEncodingOptions.cs
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/Pipeline/RealtimeEncodingOptions.cs
@@ -55,6 +55,20 @@ public int AudioInputQueueSize
public bool ForceReadback { get; set; }
+ ///
+ /// Enables the disk buffer. When set, encoded frames are written to segment files instead of being held in
+ /// memory, which lowers memory pressure and makes the footage leading up to a crash recoverable through
+ /// . is not
+ /// used in that case.
+ /// Null, the default, keeps the in-memory buffer. Assign to enable the
+ /// disk buffer with its default configuration.
+ ///
+ ///
+ /// Recording continuously to storage shortens the lifespan of flash memory, so this is intended primarily for
+ /// development and quality-assurance builds.
+ ///
+ public DiskBufferOptions? DiskBuffer { get; set; }
+
public static ref readonly RealtimeEncodingOptions Default => ref DefaultValue;
private static readonly RealtimeEncodingOptions DefaultValue =
new()
diff --git a/Packages/jp.co.cyberagent.instant-replay/Runtime/RealtimeInstantReplaySession.cs b/Packages/jp.co.cyberagent.instant-replay/Runtime/RealtimeInstantReplaySession.cs
index a1ad4f51..7726641b 100644
--- a/Packages/jp.co.cyberagent.instant-replay/Runtime/RealtimeInstantReplaySession.cs
+++ b/Packages/jp.co.cyberagent.instant-replay/Runtime/RealtimeInstantReplaySession.cs
@@ -18,7 +18,7 @@ namespace InstantReplay
public class RealtimeInstantReplaySession : IDisposable
{
private readonly AudioSampleProviderSubscription _audioPipeline;
- private readonly BoundedEncodedFrameBuffer _buffer;
+ private readonly IEncodedFrameBuffer _buffer;
private readonly EncodingSystem _encodingSystem;
private readonly object _lock = new();
private readonly TemporalController _temporalController = new();
@@ -69,7 +69,9 @@ public RealtimeInstantReplaySession(
var encodingSystem = _encodingSystem = new EncodingSystem(options.VideoOptions, options.AudioOptions);
var videoEncoder = encodingSystem.CreateVideoEncoder();
var audioEncoder = encodingSystem.CreateAudioEncoder();
- var buffer = _buffer = new BoundedEncodedFrameBuffer(options.MaxMemoryUsageBytesForCompressedFrames);
+ var buffer = _buffer = options.DiskBuffer is { } diskBufferOptions
+ ? new DiskEncodedFrameBuffer(diskBufferOptions, options.VideoOptions, options.AudioOptions)
+ : (IEncodedFrameBuffer)new BoundedEncodedFrameBuffer(options.MaxMemoryUsageBytesForCompressedFrames);
// ReSharper disable once ConvertToLocalFunction
Action onLazyVideoFrameDataDropped = async static dropped =>
@@ -97,7 +99,7 @@ public RealtimeInstantReplaySession(
options.VideoInputQueueSize,
onLazyVideoFrameDataDropped,
new VideoEncoderInput(videoEncoder,
- new BoundedEncodedDataBufferVideoInput(buffer).AsAsync())))));
+ new EncodedFrameBufferVideoInput(buffer).AsAsync())))));
}
else
{
@@ -122,7 +124,7 @@ public RealtimeInstantReplaySession(
options.VideoInputQueueSize,
onLazyVideoFrameDataDropped,
new VideoEncoderInput(videoEncoder,
- new BoundedEncodedDataBufferVideoInput(buffer).AsAsync()))))));
+ new EncodedFrameBufferVideoInput(buffer).AsAsync()))))));
}
var audioInputQueueSizeSeconds = options.AudioInputQueueSizeSeconds ?? 1.0;
@@ -138,7 +140,7 @@ public RealtimeInstantReplaySession(
options.AudioLagAdjustmentThreshold).AsInput(
new PcmAudioFrameDroppingChannelInput(audioInputQueueSizeSamples,
new AudioEncoderInput(audioEncoder,
- new BoundedEncodedDataBufferAudioInput(buffer).AsAsync()))));
+ new EncodedFrameBufferAudioInput(buffer).AsAsync()))));
_temporalController.Resume();
State = SessionState.Recording;
@@ -231,12 +233,17 @@ public async ValueTask StopAndExportAsync(double? seconds = default, str
using var muxer = _encodingSystem.CreateMuxer(outputPath);
// Get frames for the requested duration
- _buffer.GetFramesForDuration(seconds, out var videoFrames, out var audioFrames);
+ var selection = await _buffer.GetFramesForDurationAsync(seconds).ConfigureAwait(false);
// Mux the segment
- await MuxSegmentAsync(muxer, videoFrames, audioFrames);
+ await EncodedFrameMuxer.MuxAsync(muxer, selection.VideoFrames, selection.AudioFrames)
+ .ConfigureAwait(false);
State = SessionState.Completed;
+
+ // The exported file supersedes the buffer, so the session directory is no longer worth keeping.
+ if (_buffer is DiskEncodedFrameBuffer diskBuffer)
+ diskBuffer.CleanupStorage();
return outputPath;
}
catch (Exception)
@@ -246,84 +253,6 @@ public async ValueTask StopAndExportAsync(double? seconds = default, str
}
}
-
- private async ValueTask MuxSegmentAsync(Muxer muxer, ReadOnlyMemory videoFrames,
- ReadOnlyMemory audioFrames)
- {
- async ValueTask MuxVideoAsync()
- {
- Exception exception = null;
- for (var i = 0; i < videoFrames.Span.Length; i++)
- {
- var frame = videoFrames.Span[i];
- try
- {
- using (frame)
- {
- if (exception == null)
- await muxer.PushVideoDataAsync(frame).ConfigureAwait(false);
- }
- }
- catch (Exception ex)
- {
- exception = ex;
- }
- }
-
- // Errors in the input to the muxer do not propagate as exceptions in PushVideoDataAsync;
- // instead, a channel closed error occurs when attempting to input the next frame.
- // Since the actual muxer error is returned in FinishVideoAsync,
- // we should call FinishVideoAsync even if PushVideoDataAsync fails.
- await muxer.FinishVideoAsync().ConfigureAwait(false);
-
- if (exception != null)
- throw exception;
- }
-
- async ValueTask MuxAudioAsync()
- {
- Exception exception = null;
- for (var i = 0; i < audioFrames.Span.Length; i++)
- {
- var frame = audioFrames.Span[i];
- try
- {
- using (frame)
- {
- if (exception == null)
- await muxer.PushAudioDataAsync(frame).ConfigureAwait(false);
- }
- }
- catch (Exception ex)
- {
- exception = ex;
- }
- }
-
- // same as video
- await muxer.FinishAudioAsync().ConfigureAwait(false);
-
- if (exception != null)
- throw exception;
- }
-
- // Always observe both tasks even if one of them fails, so that the muxer is not
- // disposed (by the caller) while the other task is still using it.
- var whenAll = Task.WhenAll(MuxVideoAsync().AsTask(), MuxAudioAsync().AsTask());
- try
- {
- await whenAll.ConfigureAwait(false);
- }
- catch (Exception) when (whenAll.Exception is { InnerExceptions: { Count: > 1 } } aggregate)
- {
- // Awaiting Task.WhenAll rethrows only the first exception;
- // rethrow the AggregateException so that all failures are propagated.
- throw aggregate;
- }
-
- await muxer.CompleteAsync();
- }
-
///
/// Pauses the recording.
///
diff --git a/Packages/jp.co.cyberagent.instant-replay/UniEnc/Runtime/EncodedFrame.cs b/Packages/jp.co.cyberagent.instant-replay/UniEnc/Runtime/EncodedFrame.cs
index 5589bf91..3e11cb04 100644
--- a/Packages/jp.co.cyberagent.instant-replay/UniEnc/Runtime/EncodedFrame.cs
+++ b/Packages/jp.co.cyberagent.instant-replay/UniEnc/Runtime/EncodedFrame.cs
@@ -29,7 +29,7 @@ namespace UniEnc
///
/// Creates a new EncodedFrame with data copied from the source.
///
- internal static EncodedFrame CreateWithCopy(ReadOnlySpan sourceData, double timestamp,
+ public static EncodedFrame CreateWithCopy(ReadOnlySpan sourceData, double timestamp,
UniencSampleKind kind)
{
var rentedArray = ArrayPool.Shared.Rent(sourceData.Length);
diff --git a/README.ja.md b/README.ja.md
index c86e7b54..b364281a 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -40,6 +40,9 @@ Instant Replay は Unity で直近のゲームプレイ動画をいつでも保
* [CRI サポート](#cri-サポート)
* [Wwise サポート](#wwise-サポート)
* [録画状態を取得する](#録画状態を取得する)
+ * [ディスクバッファリングとクラッシュ復旧](#ディスクバッファリングとクラッシュ復旧)
+ * [クラッシュ後の復旧](#クラッシュ後の復旧)
+ * [ディスクバッファの設定](#ディスクバッファの設定)
* [無制限録画](#無制限録画)
* [レガシーモード](#レガシーモード)
* [録画時間とフレームレートの設定](#録画時間とフレームレートの設定)
@@ -275,6 +278,64 @@ Wwise もサポートされています。
`InstantReplaySession.State` プロパティで録画の状態を取得できます。
+## ディスクバッファリングとクラッシュ復旧
+
+`RealtimeInstantReplaySession` は既定ではエンコード済みのフレームをメモリ上に保持しますが、`RealtimeEncodingOptions.DiskBuffer` を設定するとディスク上のセグメントファイルに書き出すようになります。メモリ使用量を抑えられるほか、異常終了の直前までの映像がディスク上に残るため、次回起動時に復旧できます。
+
+> [!WARNING]
+> ストレージへ継続的に書き込むとフラッシュメモリの寿命を縮めます。この機能は主に開発ビルドや QA ビルドでの利用を想定しており、エンドユーザーに配布するビルドでの使用は推奨しません。
+
+```csharp
+using InstantReplay;
+
+var options = RealtimeEncodingOptions.Default;
+options.DiskBuffer = DiskBufferOptions.Default;
+
+using var session = new RealtimeInstantReplaySession(options);
+```
+
+ディスクバッファが有効な間は `MaxMemoryUsageBytesForCompressedFrames` は使用されません。代わりに `DiskBufferOptions.MaxDiskUsageBytes` が保持されるデータ量の上限になります。
+
+### クラッシュ後の復旧
+
+各セッションは専用のディレクトリに書き込みます。正常に破棄されたセッションは、`RetainOnDispose` が設定されていない限り自身のディレクトリを削除します。したがって次回起動時に残っているディレクトリは、正常に終了しなかったセッションを表します。
+
+```csharp
+using InstantReplay;
+
+foreach (var recovery in DiskEncodedFrameBufferRecovery.FindRecoverable())
+{
+ if (!recovery.IsCompatible) continue;
+
+ var path = await recovery.ExportAsync(durationSeconds: 30);
+ Debug.Log($"Recovered {path} (started at {recovery.StartedAtUtc:u}, {recovery.SizeBytes} bytes)");
+
+ recovery.Delete();
+}
+```
+
+`FindRecoverable` は指定したルートディレクトリ以下の復旧可能なセッションを列挙します。引数を省略した場合は、記録側が既定で使用するディレクトリを対象とします。異常終了が複数回発生した場合は複数のセッションが残ることがあり、どれを書き出してどれを破棄するかは呼び出し側が決定します。パスが既に判明している単一のセッションを読み取る場合は `TryGetRecoverable` を使用します。
+
+復旧処理が暗黙にディレクトリを削除することはありません。クラッシュによって残されたセッションは、それに先行する映像の唯一の複製だからです。書き出したファイルの処理が済んだ時点で `Delete()` を呼び出してください。
+
+`IsCompatible` は、マニフェストに記録されたプラットフォームとパッケージバージョンを実行中のアプリケーションと比較します。ただしこれは必要条件であって十分条件ではありません。ペイロードはネイティブライブラリによってシリアライズされており、そのスキーマはパッケージのバージョン間で変わりうるためです。この種の不一致は `ExportAsync` の失敗として表面化します。
+
+### ディスクバッファの設定
+
+| プロパティ | 既定値 | 説明 |
+|---|---|---|
+| `Directory` | `null` | セッションディレクトリを格納するディレクトリ。`null` または空の場合は `Application.temporaryCachePath/InstantReplay/DiskBuffer` が使用されます (`DiskBufferOptions.GetDefaultDirectory()` で取得できます)。 |
+| `MaxDiskUsageBytes` | 256 MiB | 1 セッションディレクトリのサイズ上限。マニフェスト、コーデック設定、全セグメントを含みます。`DiskBufferOptions.MinimumDiskUsageBytes` (4 MiB) 以上である必要があります。 |
+| `SegmentDuration` | 5.0 | 1 セグメントファイルの目標長さ (秒)。 |
+| `MaxSegmentBytes` | 8 MiB | 1 セグメントファイルのサイズ上限。`MaxDiskUsageBytes` を超えることはできません。 |
+| `MaxPendingWriteBytes` | 4 MiB | 書き込みキューで待機するペイロードの合計サイズ上限。キューが一杯の間に到着したフレームは、エンコーダーをブロックせず破棄されます。 |
+| `RetainOnDispose` | `false` | セッションが正常に破棄されたときにディレクトリを残すかどうか。 |
+| `SyncMode` | `OperatingSystem` | フラッシュ方針。下記を参照してください。 |
+
+`MaxDiskUsageBytes` は目標値ではなく上限です。各レコードの書き込み前に領域を予約し、その予約が必要なだけ古いセグメントを削除するため、ディレクトリのサイズが一瞬たりとも上限を超えることはありません。削除可能なセグメントをすべて削除しても上限を満たせない場合は、書き込まずにレコードを破棄します。したがって `MaxSegmentBytes` に近い値を指定すると、保持時間ではなく録画そのものが劣化します。セグメントは映像のキーフレームで閉じられるため、セグメントを1つ破棄しても不完全な GOP が残ることはありません。
+
+`SyncMode` は、どの障害モードに備えるかを選択します。既定の `DiskBufferSyncMode.OperatingSystem` は、書き込みキューから取り出したバッチごとにデータを OS へ渡し、セグメントを閉じる際にストレージデバイスへフラッシュします。記録されたフレームはプロセスのクラッシュ (ネイティブフォールト、OOM kill、abort) を生き延びます。これがクラッシュ復旧の目的とする障害モードであり、デバイスへの追加の書き込みを必要としません。電源断やカーネルパニックでは、現在のセグメントを開いてから書き込んだレコードが失われる可能性があります。`DiskBufferSyncMode.EveryRecord` は全レコードをデバイスへフラッシュするため電源断にも耐えますが、フレームごとに1回のデバイスフラッシュが発生します。フラッシュメモリの摩耗が著しく増えるため、常用ではなくストレージ層の問題の診断を想定しています。
+
## 無制限録画
`UnboundedRecordingSession` を使用すると、エンコードしたデータをメモリに保持せず直接ディスク上の MP4 ファイルに書き出します。書き出せる動画ファイルの時間には制限が設定されず、ディスク容量の許す限り録画が行えます。コンストラクタで出力ファイルパスの指定が必要な以外は `RealtimeInstantReplaySession` と同様に使用できます。
diff --git a/README.md b/README.md
index a510159c..b612c707 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,9 @@ When a bug occurs, you can export the recent gameplay leading up to the bug as a
* [CRI Support](#cri-support)
* [Wwise Support](#wwise-support)
* [Getting the Recording State](#getting-the-recording-state)
+ * [Disk Buffering and Crash Recovery](#disk-buffering-and-crash-recovery)
+ * [Recovering After a Crash](#recovering-after-a-crash)
+ * [Disk Buffer Options](#disk-buffer-options)
* [Unbounded Recording](#unbounded-recording)
* [Legacy Mode](#legacy-mode)
* [Setting Recording Time and Frame Rate](#setting-recording-time-and-frame-rate)
@@ -279,6 +282,64 @@ Wwise is also supported via `InstantReplay.Wwise.WwiseAudioSampleProvider`.
You can get the recording state with the `RealtimeInstantReplaySession.State` property.
+## Disk Buffering and Crash Recovery
+
+By default, `RealtimeInstantReplaySession` holds encoded frames in memory. Assigning `RealtimeEncodingOptions.DiskBuffer` writes them to segment files on disk instead. This lowers memory pressure, and it leaves the footage that preceded an abnormal termination on disk, where a later run of the application can recover it.
+
+> [!WARNING]
+> Recording continuously to storage shortens the lifespan of flash memory. This is intended primarily for development and quality-assurance builds rather than for builds shipped to end users.
+
+```csharp
+using InstantReplay;
+
+var options = RealtimeEncodingOptions.Default;
+options.DiskBuffer = DiskBufferOptions.Default;
+
+using var session = new RealtimeInstantReplaySession(options);
+```
+
+`MaxMemoryUsageBytesForCompressedFrames` is not used while the disk buffer is enabled. `DiskBufferOptions.MaxDiskUsageBytes` bounds the retained data instead.
+
+### Recovering After a Crash
+
+Each session writes into its own directory. A session that is disposed normally deletes its directory unless `RetainOnDispose` is set, so a directory still present on the next run denotes a session that did not end normally.
+
+```csharp
+using InstantReplay;
+
+foreach (var recovery in DiskEncodedFrameBufferRecovery.FindRecoverable())
+{
+ if (!recovery.IsCompatible) continue;
+
+ var path = await recovery.ExportAsync(durationSeconds: 30);
+ Debug.Log($"Recovered {path} (started at {recovery.StartedAtUtc:u}, {recovery.SizeBytes} bytes)");
+
+ recovery.Delete();
+}
+```
+
+`FindRecoverable` enumerates every recoverable session below the given root directory, or below the directory the recorder uses by default when none is passed. Several may be present when the application has terminated abnormally more than once, and the caller decides which to export and which to discard. `TryGetRecoverable` reads a single session directory when its path is already known.
+
+Recovery never deletes anything implicitly, because a session left behind by a crash is the only copy of the footage that preceded it. Call `Delete()` once the exported file has been dealt with.
+
+`IsCompatible` compares the platform and package version recorded in the manifest against the running application. It is necessary but not sufficient: the payloads are serialized by the native library, whose schema may change between package versions, and a mismatch of that kind surfaces as a failure from `ExportAsync` instead.
+
+### Disk Buffer Options
+
+| Property | Default | Description |
+|---|---|---|
+| `Directory` | `null` | Directory holding the session directories. `null` or empty uses `Application.temporaryCachePath/InstantReplay/DiskBuffer`, available as `DiskBufferOptions.GetDefaultDirectory()`. |
+| `MaxDiskUsageBytes` | 256 MiB | Upper bound of one session directory, covering the manifest, the codec configuration, and every segment. Must be at least `DiskBufferOptions.MinimumDiskUsageBytes` (4 MiB). |
+| `SegmentDuration` | 5.0 | Target duration of one segment file, in seconds. |
+| `MaxSegmentBytes` | 8 MiB | Upper bound of one segment file. Must not exceed `MaxDiskUsageBytes`. |
+| `MaxPendingWriteBytes` | 4 MiB | Upper bound of the payload waiting in the write queue. Frames arriving while it is full are dropped rather than blocking the encoder. |
+| `RetainOnDispose` | `false` | Whether the session directory is kept when the session is disposed normally. |
+| `SyncMode` | `OperatingSystem` | Flush policy. See below. |
+
+`MaxDiskUsageBytes` is a bound rather than a target. Space is reserved before each record is written, and the reservation deletes as many of the oldest segments as it needs, so the directory never exceeds the bound at any instant. When the bound cannot be met even after every evictable segment has been deleted, records are dropped rather than written; a value close to `MaxSegmentBytes` therefore degrades the recording rather than the retention. Segments are closed at video key frames, so discarding one never leaves a partial group of pictures behind.
+
+`SyncMode` selects which failure mode to defend against. `DiskBufferSyncMode.OperatingSystem`, the default, hands data to the operating system after every batch and flushes it to the storage device when a segment is closed. Recorded frames survive a process crash — a native fault, an out-of-memory kill, or an abort — which is what crash recovery targets, and this costs no additional device writes. Power loss or a kernel panic loses at most the records written since the current segment was opened. `DiskBufferSyncMode.EveryRecord` flushes every record to the device, which survives power loss as well, at the cost of one device flush per frame. It markedly increases wear on flash memory and is intended for diagnosing storage-layer problems rather than for routine use.
+
## Unbounded Recording
`UnboundedRecordingSession` writes encoded data directly to an MP4 file on disk without keeping it in memory, enabling unbounded recording limited only by available disk space. Apart from the required output file path in the constructor, `UnboundedRecordingSession` is used similarly to `RealtimeInstantReplaySession`.
diff --git a/docs/disk-buffered-recording.md b/docs/disk-buffered-recording.md
new file mode 100644
index 00000000..c71453a1
--- /dev/null
+++ b/docs/disk-buffered-recording.md
@@ -0,0 +1,404 @@
+# Disk-Buffered Recording and Crash Recovery
+
+This document describes the design of the opt-in disk buffer for `RealtimeInstantReplaySession`
+(issue #39).
+
+## Motivation
+
+`RealtimeInstantReplaySession` keeps encoded frames in a bounded in-memory ring buffer
+(`BoundedEncodedFrameBuffer`, 20 MiB by default). Two limitations follow from this:
+
+1. The buffer competes with the application for memory, which constrains the recordable duration on
+ memory-limited devices.
+2. The buffer is lost when the process terminates abnormally, so the footage leading up to a crash
+ cannot be examined.
+
+The disk buffer addresses both by writing encoded frames to persistent storage as they are produced,
+and by allowing a later process to reconstruct an MP4 file from the frames a crashed process left
+behind.
+
+The trade-off is an increase in storage traffic, which shortens the lifespan of flash memory.
+Because the feature is intended primarily for development and quality-assurance builds, it is
+disabled by default and must be enabled explicitly.
+
+## Scope
+
+The feature is implemented entirely in the C# layer. No change to the native library (UniEnc) is
+required, because the payload the C# layer receives from the encoder is already a self-contained
+byte sequence:
+
+- `UniEnc.EncodedFrame` holds an opaque payload, a timestamp, and a sample kind.
+- The payload is a `bincode` serialization of the platform's encoded-data type, produced by the
+ encoder and consumed by the muxer (`unienc_muxer_push_video` and `unienc_muxer_push_audio` decode
+ it again).
+- The serialized form contains no pointers or process-local handles. On Apple platforms, for
+ example, `CMSampleBuffer` is converted to a plain structure holding H.264 parameter sets, timing
+ information, and sample bytes.
+
+A payload written by one process can therefore be pushed into a muxer created by another process,
+provided both processes run the same native library on the same platform. This premise is verified
+end to end by the automated checks described under **Verification**.
+
+## 1. On-Disk Format
+
+### Directory layout
+
+```
+/
+ /
+ manifest.json
+ metadata.irb
+ seg-00000000.irb
+ seg-00000001.irb
+ ...
+```
+
+`` defaults to `Application.temporaryCachePath/InstantReplay/DiskBuffer`. `` is
+`yyyyMMdd_HHmmssfff` followed by a process-wide counter, which keeps the identifier unique across
+sessions created within the same millisecond.
+
+### Record structure
+
+Segment files and the metadata file share one record format. Records are appended without padding:
+
+| Offset | Size | Field | Description |
+| -----: | ---: | --------------- | ------------------------------------------------------------ |
+| 0 | 4 | `payloadLength` | Payload length in bytes, little-endian |
+| 4 | 1 | `track` | `0` video, `1` audio |
+| 5 | 1 | `kind` | `UniencSampleKind`: `0` interpolated, `1` key, `2` metadata |
+| 6 | 2 | `reserved` | Written as zero |
+| 8 | 8 | `timestamp` | Presentation timestamp in seconds, IEEE 754 double |
+| 16 | 4 | `crc32` | CRC-32 (IEEE 802.3 polynomial) over the payload |
+| 20 | var | `payload` | The `EncodedFrame` payload verbatim |
+
+Each file begins with a 16-byte header holding the magic value `IRSG`, the format version, and the
+segment index (`0xFFFFFFFF` for the metadata file).
+
+Integers are read and written with explicit shifts rather than `BinaryPrimitives`, and the code
+contains no conditional compilation, so the same source compiles under every API compatibility
+level the package supports.
+
+### Segment rotation
+
+A segment is closed and a new one opened when the record about to be written is a video key frame
+**and** either the segment has covered `SegmentDuration` seconds or it has reached
+`MaxSegmentBytes`.
+
+Requiring a key frame makes each segment independently decodable from its first video record, so
+discarding an older segment never leaves a partial group of pictures at the head of the buffer. The
+size condition is a safety valve. With the one-second IDR interval the encoders currently produce,
+the overshoot beyond the target is bounded by approximately one second.
+
+### Codec configuration
+
+Records whose kind is `UniencSampleKind.Metadata` carry codec configuration: H.264 parameter sets,
+the AAC `AudioSpecificConfig`, or the platform equivalent. They are emitted once at the start of the
+stream and are required in order to mux any later frame.
+
+They are therefore written to `metadata.irb`, which is never evicted, rather than into a segment.
+Keeping them only in memory would make recovery impossible on every platform that emits them, which
+is the defect this design exists to avoid: `unienc_android_mc`, `unienc_windows_mf`, and
+`unienc_ffmpeg` all produce metadata records, while `unienc_apple_vt` and `unienc_webcodecs` do not,
+because on Apple platforms the parameter sets travel inside every sample. The reader treats a
+missing or empty metadata file as normal rather than as an error, so both kinds of platform recover
+correctly.
+
+Only distinct payloads are stored. A platform that reissued the same configuration repeatedly would
+otherwise erode the space reserved for segments. The metadata file is additionally capped at 1 MiB.
+
+Repeating the configuration at the head of every segment was considered and rejected: the encoder
+does not reissue it, so it would have to be cached and rewritten, which gains nothing over a single
+unevictable file.
+
+### Session manifest
+
+`manifest.json` is written once, before any frame is accepted, and is flushed to the storage device
+immediately. It records what a later process needs in order to build a compatible muxer:
+
+```json
+{
+ "formatVersion": 2,
+ "startedAtUtc": "2026-08-19T05:00:00.0000000Z",
+ "platform": "Android",
+ "unityVersion": "2022.3.0f1",
+ "applicationVersion": "1.0.0",
+ "videoWidth": 1280,
+ "videoHeight": 720,
+ "videoFpsHint": 30,
+ "videoBitrate": 2500000,
+ "audioSampleRate": 44100,
+ "audioChannels": 2,
+ "audioBitrate": 128000
+}
+```
+
+The encoder options are recorded because `EncodingSystem` requires them and creating the muxer
+requires an `EncodingSystem`. Creating one is inexpensive: the native constructor only stores the
+options, and the platform encoders are instantiated lazily by `CreateVideoEncoder` and
+`CreateAudioEncoder`, which the recovery path never calls.
+
+The document is a flat object of strings and numbers, serialized and parsed by hand rather than with
+`UnityEngine.JsonUtility`. This keeps the whole storage layer free of any dependency on UnityEngine,
+which is what allows it to be tested outside the Unity Editor. A nested document is rejected rather
+than partially accepted.
+
+## 2. The Size Limit Is a Hard Bound
+
+`MaxDiskUsageBytes` bounds the total size of the session directory — the manifest, the metadata
+file, and every segment — and it is a bound rather than a target.
+
+The guarantee is upheld by reserving space before writing rather than trimming afterwards. Before
+each record is appended, and before each new segment file is created, the writer computes the size
+the operation would produce and deletes the oldest closed segments until the result fits. When the
+result still does not fit, the record is dropped and the write does not happen. The directory
+therefore never exceeds the limit at any instant, not even transiently between a write and a
+subsequent eviction.
+
+Rotation is ordered so that the buffer cannot deadlock. The open segment is closed first, which
+makes it evictable, and only then is space reserved for the new segment. Without this ordering, a
+single open segment that had grown to fill the budget could never be reclaimed and every subsequent
+record would be dropped forever.
+
+Two consequences follow and are documented on the option itself:
+
+- Setting `MaxDiskUsageBytes` close to `MaxSegmentBytes` degrades the recording rather than the
+ retention, because the writer prefers dropping records to exceeding the bound. `Validate` rejects
+ a limit below 4 MiB and a limit smaller than `MaxSegmentBytes`.
+- The metadata file and the manifest are counted against the limit and are never evicted, so they
+ reduce the space available to segments. Together they occupy a few kilobytes.
+
+An alternative was considered in which the limit applied only to segment files. It was rejected
+because it would make the number the user sets differ from the amount of storage the feature
+actually consumes, which is the property the user is trying to control.
+
+## 3. Durability
+
+### Flush policy
+
+Two failure modes are distinguished and treated differently:
+
+- **Process termination** — a native fault, an out-of-memory kill, or an abort. Data that has
+ reached the operating system through a write survives this, because the kernel owns the page cache
+ and flushes it independently of the process. Only data still held in the user-space `FileStream`
+ buffer is lost.
+- **Power loss or a kernel panic** — data survives only if it has been flushed to the storage device.
+
+Recovering the footage that precedes a crash targets the first mode. Defending against it costs no
+additional device writes, because it requires only a write system call. Defending against the second
+requires a device flush, which multiplies the erase cycles the storage device performs.
+
+The default policy, `DiskBufferSyncMode.OperatingSystem`, is therefore:
+
+- After every batch drained from the write queue, the open segment is flushed to the operating
+ system and no further.
+- When a segment is closed, it is flushed to the storage device. This bounds the exposure to power
+ loss to roughly one segment while keeping device flushes to approximately one every five seconds,
+ rather than the thirty to forty per second a per-record flush would cause.
+- The manifest and every codec configuration record are flushed to the storage device immediately.
+ They are written a handful of times per session and the buffer is worthless without them.
+
+`DiskBufferSyncMode.EveryRecord` flushes every record to the device. It is documented as intended
+for diagnosing storage-layer problems rather than for routine use, because of its effect on flash
+wear.
+
+### Recovering a torn file
+
+A process killed mid-write leaves a truncated final record. Because records are appended and never
+rewritten in place, a truncation can only occur at the end of a file. The reader detects it without
+a separate journal:
+
+1. Read the file header. If the magic value or the format version does not match, discard the file.
+2. Read a 20-byte record header. If fewer than 20 bytes remain, stop.
+3. If `payloadLength` is negative, exceeds the implementation limit, or exceeds the bytes remaining
+ in the file, stop. The same applies to an out-of-range track or kind, or a non-finite timestamp.
+4. Accept the record and continue.
+
+Scanning reads headers only and seeks over payloads, so it does not have to read the whole buffer.
+The checksum is verified when a payload is read for muxing; a record whose checksum does not match
+truncates the stream there, because a decoder cannot proceed past a corrupt sample. Scanning stops
+at the first record that cannot be complete, rather than skipping it and continuing, so a stream
+with a hole in the middle is never produced.
+
+## 4. Recovery API
+
+```csharp
+public sealed class DiskEncodedFrameBufferRecovery
+{
+ public static bool TryGetRecoverable(string storagePath, out DiskEncodedFrameBufferRecovery recovery);
+ public static IReadOnlyList FindRecoverable(string rootDirectory = null);
+
+ public string StoragePath { get; }
+ public DateTime StartedAtUtc { get; }
+ public string Platform { get; }
+ public string ApplicationVersion { get; }
+ public bool IsCompatible { get; }
+ public long SizeBytes { get; }
+
+ public ValueTask ExportAsync(double? durationSeconds = null, string outputPath = null);
+ public void Delete();
+}
+```
+
+A session that is disposed normally removes its own directory, so every directory that remains
+denotes an abnormal termination. `FindRecoverable` enumerates them; several may be present when the
+application has crashed more than once, and the caller decides which to export and which to delete.
+
+Recovery never deletes anything implicitly. `Delete` must be called explicitly, so that a failed
+export can be retried and the raw directory can still be retrieved from the device. An earlier
+design in which `Dispose` deleted the directory was rejected: a session left behind by a crash is
+the only copy of the footage that preceded it, and destroying it as a side effect of a failed export
+defeats the purpose of the feature.
+
+`IsCompatible` reports whether the format version and the platform match the running build. These
+conditions are necessary but not sufficient. The payload is a `bincode` serialization of a
+platform-specific structure belonging to the native library, and that structure may change between
+package versions without any signal the C# layer can observe. `ExportAsync` therefore proceeds with
+a warning rather than refusing, and a genuine mismatch surfaces as a decode failure from the muxer.
+Refusing whenever the version differed was rejected because it would make recovery useless after any
+package update, including updates that do not touch the serialization.
+
+`ExportAsync` selects frames and muxes them through `EncodedFrameMuxer`, the same helper
+`RealtimeInstantReplaySession` uses. Both therefore follow the completion protocol the muxer
+requires, in which `FinishVideoAsync` and `FinishAudioAsync` are called even after a push has
+failed, because that is where the muxer reports the underlying error.
+
+## 5. Opt-In API
+
+```csharp
+public struct RealtimeEncodingOptions
+{
+ // ... existing members ...
+ public DiskBufferOptions? DiskBuffer { get; set; }
+}
+
+public struct DiskBufferOptions
+{
+ public string Directory { get; set; }
+ public long MaxDiskUsageBytes { get; set; }
+ public double SegmentDuration { get; set; }
+ public long MaxSegmentBytes { get; set; }
+ public long MaxPendingWriteBytes { get; set; }
+ public bool RetainOnDispose { get; set; }
+ public DiskBufferSyncMode SyncMode { get; set; }
+
+ public static ref readonly DiskBufferOptions Default { get; }
+}
+```
+
+`DiskBuffer` is null by default, so existing behaviour is unchanged. When it is set,
+`MaxMemoryUsageBytesForCompressedFrames` is not used.
+
+| Member | Default |
+| ---------------------- | ---------------------------------------------------------- |
+| `Directory` | `Application.temporaryCachePath/InstantReplay/DiskBuffer` |
+| `MaxDiskUsageBytes` | 256 MiB |
+| `SegmentDuration` | 5 seconds |
+| `MaxSegmentBytes` | 8 MiB |
+| `MaxPendingWriteBytes` | 4 MiB |
+| `RetainOnDispose` | `false` |
+| `SyncMode` | `DiskBufferSyncMode.OperatingSystem` |
+
+`Application.temporaryCachePath` is chosen because it is writable on every supported platform, is
+excluded from backup on iOS, and is not visible to the user, so a leftover buffer does not appear as
+clutter in the user's file browser.
+
+## 6. Relationship to the In-Memory Buffer
+
+When `DiskBuffer` is set, the disk buffer replaces the in-memory buffer rather than augmenting it.
+Encoded payloads are held in memory only while they sit in the write queue.
+
+Both implementations satisfy `IEncodedFrameBuffer`, so the pipeline construction in
+`RealtimeInstantReplaySession` differs only in which implementation it instantiates.
+
+Frame selection — locating the key frame nearest to `latest - duration`, aligning the audio start,
+rebasing timestamps to zero, and prepending the codec configuration — is identical for both and is
+implemented once in `EncodedFrameSelector`. The disk implementation reads its records back from the
+files rather than keeping a parallel in-memory index, so the live export path and the crash-recovery
+path run the same reader and the same selection, and every export exercises the recovery code.
+
+## 7. I/O Threading
+
+Encoder output is delivered on a background thread by `VideoEncoderInput` and `AudioEncoderInput`.
+Writing synchronously on that thread would couple the encoder drain loop to storage latency, so
+writes are handed to a queue drained by one dedicated thread.
+
+The queue is bounded by the total payload size of its entries, capped at `MaxPendingWriteBytes`.
+When the bound is reached the incoming frame is dropped and a warning is logged once, rather than
+blocking the encoder. This follows the behaviour `DroppingChannelInput` already applies to raw
+frames. A drop leaves a gap in the stream, and playback may show artefacts until the next key frame;
+the alternative, blocking the encoder, would stall capture and drop frames further upstream anyway.
+
+Frames are queued as `EncodedFrame` values, which already own pooled arrays, and the writer combines
+the record header and the payload in a scratch buffer that is grown once and reused. No allocation
+occurs per frame on either the producer or the writer side.
+
+Shutdown order, on both `Dispose` and export:
+
+1. Stop accepting new frames.
+2. Complete the queue and join the writer thread, so every accepted frame reaches a file.
+3. Flush the open segment to the storage device and close it.
+4. For export, read the files back and select frames.
+5. On `Dispose`, delete the session directory unless `RetainOnDispose` is set. After a successful
+ export the directory is deleted, because the exported file supersedes it.
+
+## 8. Verification
+
+The storage layer has no dependency on UnityEngine, so it is compiled directly into
+`InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests` and exercised without the Unity Editor:
+
+```bash
+cd InstantReplay.Externals/src/InstantReplay.DiskBuffer.Tests
+dotnet run
+```
+
+The project links the storage-layer sources rather than copying them, so a UnityEngine reference
+added to one of those files breaks the build, which is the intended guard.
+
+The checks cover the record round trip, recovery from a file truncated in the middle of a payload
+and from one truncated in the middle of a record header, detection of a corrupt payload by checksum,
+the hard bound on disk usage under sustained eviction, survival of the codec configuration across
+eviction of every segment, deduplication of repeated configuration, the key-frame alignment of
+segment boundaries, the manifest round trip, and frame selection.
+
+The final check is an end-to-end run: it drives the real platform encoder, persists every encoded
+frame through the disk buffer, reads the buffer back from the files and the manifest alone, and
+muxes the result. It is skipped when the native library for the running platform is absent. On macOS
+it produces a valid MP4 that `ffprobe` reports as H.264 with the expected frame count alongside an
+AAC track, and that `ffmpeg` decodes without error. This is the check that validates the premise of
+the feature.
+
+## 9. Risks and Open Questions
+
+**Storage-device wear.** The default flush policy avoids device-level flushes except at segment
+boundaries, but the data itself is still written. At the default bitrate of 2.5 Mbps the buffer
+writes roughly 320 KiB per second, or about 1.1 GiB per hour. Continuous use over the lifetime of a
+product is not advisable, which is why the feature is disabled by default and documented as intended
+for development and quality-assurance builds.
+
+**Platform differences.** `Application.temporaryCachePath` resolves to the application's cache
+directory on every supported platform, so no permission is required and no additional Android
+manifest entry is needed. The operating system may reclaim the cache directory when storage runs
+low, so a session directory can disappear between the crash and the recovery attempt;
+`FindRecoverable` simply will not list it. Writing to external storage on Android was not chosen,
+because it would require a runtime permission and would make the data visible to the user. WebGL is
+not supported, because it has neither a persistent filesystem by default nor threads.
+
+**Free-space exhaustion.** `MaxDiskUsageBytes` bounds the buffer, but the device may run out of space
+for other reasons. A write failure is reported and the frame is dropped; the session continues
+recording so that the application is not disrupted, and sessions already written remain recoverable.
+
+**Relationship to `UnboundedRecordingSession`.** That session writes an MP4 continuously through the
+muxer, so it does not benefit from a replay buffer and is not covered here. It is not crash-resilient
+either: an MP4 whose `moov` box was never written is not playable. Making it crash-resilient requires
+either fragmented MP4 output or a repair pass, and is out of scope.
+
+**Relationship to the legacy mode.** `InstantReplaySession` writes JPEG frames and PCM audio to disk
+and transcodes them on export. Its intermediate files survive a crash, but it has no recovery entry
+point, its intermediate representation is far larger than an encoded stream, and its export latency
+is high. The disk buffer supersedes that approach for the realtime pipeline; the legacy mode is left
+unchanged.
+
+**Payload compatibility across package versions.** Discussed in section 4. There is no mechanism by
+which the C# layer can detect that the native serialization has changed. If this becomes a practical
+problem, a version constant exported from the native library through the FFI and recorded in the
+manifest would resolve it, at the cost of a native change.