Skip to content

Commit 0adef2b

Browse files
Play clips as one continuous stream to remove chunk-boundary stalls (#46)
1 parent 4883f3e commit 0adef2b

10 files changed

Lines changed: 761 additions & 135 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace SentryReplay;
2+
3+
/// <summary>
4+
/// Per-camera ffconcat playlists covering an entire clip, so playback can flow continuously
5+
/// across chunk boundaries without reopening media.
6+
/// </summary>
7+
public sealed record class ClipMediaSource(
8+
TimeSpan Duration,
9+
IReadOnlyList<TimeSpan> ChunkStarts,
10+
IReadOnlyDictionary<string, string> CameraPlaylistPaths);
11+
12+
/// <summary>
13+
/// Builds a <see cref="ClipMediaSource"/> for a clip.
14+
/// </summary>
15+
public interface IClipMediaSourceBuilder
16+
{
17+
ClipMediaSource Build(CamClip clip);
18+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using System.Text.RegularExpressions;
4+
using Serilog;
5+
6+
namespace SentryReplay;
7+
8+
/// <summary>
9+
/// Builds per-camera ffconcat playlists on disk so a whole clip can be opened by FFmpeg's
10+
/// concat demuxer in one go.
11+
/// </summary>
12+
public partial class FfconcatMediaSourceBuilder : IClipMediaSourceBuilder
13+
{
14+
private const double FallbackChunkSeconds = 60;
15+
16+
private static readonly string PlaylistDirectory =
17+
Path.Combine(Path.GetTempPath(), "SentryReplay", "playlists");
18+
19+
public ClipMediaSource Build(CamClip clip)
20+
{
21+
ArgumentNullException.ThrowIfNull(clip);
22+
23+
Directory.CreateDirectory(PlaylistDirectory);
24+
25+
var chunkDurations = clip.Chunks
26+
.Select(chunk => ProbeChunkDuration(chunk))
27+
.ToList();
28+
29+
var chunkStarts = new List<TimeSpan>(chunkDurations.Count);
30+
var runningStart = TimeSpan.Zero;
31+
foreach (var chunkDuration in chunkDurations)
32+
{
33+
chunkStarts.Add(runningStart);
34+
runningStart += chunkDuration;
35+
}
36+
37+
var playlistPaths = new Dictionary<string, string>();
38+
var clipToken = SanitizeForFileName(clip.Name);
39+
40+
foreach (var camera in CameraNames.All)
41+
{
42+
if (clip.Chunks.Count == 0 || !clip.Chunks[0].Files.ContainsKey(camera))
43+
{
44+
continue;
45+
}
46+
47+
var entries = new List<(string FilePath, TimeSpan Duration)>();
48+
for (var i = 0; i < clip.Chunks.Count; i++)
49+
{
50+
if (!clip.Chunks[i].Files.TryGetValue(camera, out var file))
51+
{
52+
break;
53+
}
54+
55+
entries.Add((file.FullPath, chunkDurations[i]));
56+
}
57+
58+
var playlistPath = Path.Combine(PlaylistDirectory, $"{clipToken}-{camera}.ffconcat");
59+
WritePlaylist(playlistPath, entries);
60+
playlistPaths[camera] = playlistPath;
61+
}
62+
63+
var duration = chunkStarts.Count == 0
64+
? TimeSpan.Zero
65+
: chunkStarts[^1] + chunkDurations[^1];
66+
67+
return new ClipMediaSource(duration, chunkStarts, playlistPaths);
68+
}
69+
70+
private static TimeSpan ProbeChunkDuration(CamChunk chunk)
71+
{
72+
if (chunk.Files.TryGetValue(CameraNames.Front, out var frontFile))
73+
{
74+
var probed = Mp4DurationReader.TryReadDuration(frontFile.FullPath);
75+
if (probed is { } duration && duration > TimeSpan.Zero)
76+
{
77+
return duration;
78+
}
79+
80+
Log.Debug(
81+
"Falling back to estimated chunk duration. ChunkTimestamp={ChunkTimestamp}; File={File}",
82+
chunk.Timestamp,
83+
frontFile.FullPath);
84+
}
85+
86+
return TimeSpan.FromSeconds(FallbackChunkSeconds);
87+
}
88+
89+
private static void WritePlaylist(string path, IReadOnlyList<(string FilePath, TimeSpan Duration)> entries)
90+
{
91+
var builder = new StringBuilder();
92+
builder.AppendLine("ffconcat version 1.0");
93+
94+
foreach (var (filePath, duration) in entries)
95+
{
96+
builder.Append("file '").Append(EscapeConcatPath(filePath)).AppendLine("'");
97+
builder.Append("duration ").AppendLine(duration.TotalSeconds.ToString("F6", CultureInfo.InvariantCulture));
98+
}
99+
100+
File.WriteAllText(path, builder.ToString());
101+
}
102+
103+
private static string EscapeConcatPath(string path)
104+
{
105+
return path.Replace('\\', '/').Replace("'", "'\\''");
106+
}
107+
108+
private static string SanitizeForFileName(string name)
109+
{
110+
var sanitized = InvalidFileNameCharsRegex().Replace(name ?? string.Empty, "_");
111+
return string.IsNullOrEmpty(sanitized) ? "clip" : sanitized;
112+
}
113+
114+
[GeneratedRegex(@"[^a-zA-Z0-9_-]+")]
115+
private static partial Regex InvalidFileNameCharsRegex();
116+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using System.Buffers.Binary;
2+
3+
namespace SentryReplay;
4+
5+
/// <summary>
6+
/// Reads an mp4 file's duration by parsing its box structure, without decoding any media.
7+
/// </summary>
8+
public static class Mp4DurationReader
9+
{
10+
private const int BoxHeaderSize = 8;
11+
private const int LargeSizeFieldSize = 8;
12+
13+
/// <summary>
14+
/// Returns the duration encoded in the file's "moov/mvhd" box, or null if it cannot be
15+
/// determined (missing boxes, malformed data, or any IO error).
16+
/// </summary>
17+
public static TimeSpan? TryReadDuration(string path)
18+
{
19+
try
20+
{
21+
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
22+
var moovBox = FindBox(stream, "moov", stream.Length);
23+
if (moovBox is null)
24+
return null;
25+
26+
var (moovStart, moovEnd) = moovBox.Value;
27+
var mvhdBox = FindBox(stream, "mvhd", moovEnd, moovStart);
28+
if (mvhdBox is null)
29+
return null;
30+
31+
var (mvhdStart, mvhdEnd) = mvhdBox.Value;
32+
return ReadMvhdDuration(stream, mvhdStart, mvhdEnd);
33+
}
34+
catch (IOException)
35+
{
36+
return null;
37+
}
38+
catch (UnauthorizedAccessException)
39+
{
40+
return null;
41+
}
42+
}
43+
44+
/// <summary>
45+
/// Scans sibling boxes starting at <paramref name="start"/> until <paramref name="end"/> for
46+
/// one matching <paramref name="type"/>, returning its (contentStart, contentEnd) range.
47+
/// </summary>
48+
private static (long ContentStart, long ContentEnd)? FindBox(FileStream stream, string type, long end, long start = 0)
49+
{
50+
Span<byte> header = stackalloc byte[BoxHeaderSize];
51+
Span<byte> largeSize = stackalloc byte[LargeSizeFieldSize];
52+
var position = start;
53+
54+
while (position + BoxHeaderSize <= end)
55+
{
56+
stream.Position = position;
57+
if (!ReadFully(stream, header))
58+
return null;
59+
60+
var size = (long)BinaryPrimitives.ReadUInt32BigEndian(header);
61+
var boxType = System.Text.Encoding.ASCII.GetString(header[4..8]);
62+
var headerSize = BoxHeaderSize;
63+
64+
if (size == 1)
65+
{
66+
if (!ReadFully(stream, largeSize))
67+
return null;
68+
69+
size = (long)BinaryPrimitives.ReadUInt64BigEndian(largeSize);
70+
headerSize += LargeSizeFieldSize;
71+
}
72+
else if (size == 0)
73+
{
74+
size = end - position;
75+
}
76+
77+
if (size < headerSize)
78+
return null;
79+
80+
var contentStart = position + headerSize;
81+
var contentEnd = position + size;
82+
83+
if (boxType == type)
84+
return (contentStart, contentEnd);
85+
86+
position += size;
87+
}
88+
89+
return null;
90+
}
91+
92+
private static TimeSpan? ReadMvhdDuration(FileStream stream, long contentStart, long contentEnd)
93+
{
94+
// version (1 byte) + flags (3 bytes)
95+
if (contentEnd - contentStart < 4)
96+
return null;
97+
98+
stream.Position = contentStart;
99+
var version = stream.ReadByte();
100+
if (version < 0)
101+
return null;
102+
103+
stream.Position = contentStart + 4;
104+
105+
uint timescale;
106+
ulong duration;
107+
108+
if (version == 1)
109+
{
110+
// 8 (ctime) + 8 (mtime) = 16 bytes to skip, then timescale (4) + duration (8)
111+
Span<byte> buffer = stackalloc byte[16 + 4 + 8];
112+
if (contentEnd - stream.Position < buffer.Length || !ReadFully(stream, buffer))
113+
return null;
114+
115+
timescale = BinaryPrimitives.ReadUInt32BigEndian(buffer[16..20]);
116+
duration = BinaryPrimitives.ReadUInt64BigEndian(buffer[20..28]);
117+
}
118+
else
119+
{
120+
// 4 (ctime) + 4 (mtime) = 8 bytes to skip, then timescale (4) + duration (4)
121+
Span<byte> buffer = stackalloc byte[8 + 4 + 4];
122+
if (contentEnd - stream.Position < buffer.Length || !ReadFully(stream, buffer))
123+
return null;
124+
125+
timescale = BinaryPrimitives.ReadUInt32BigEndian(buffer[8..12]);
126+
duration = BinaryPrimitives.ReadUInt32BigEndian(buffer[12..16]);
127+
}
128+
129+
if (timescale == 0)
130+
return null;
131+
132+
return TimeSpan.FromSeconds((double)duration / timescale);
133+
}
134+
135+
private static bool ReadFully(FileStream stream, Span<byte> buffer)
136+
{
137+
var totalRead = 0;
138+
while (totalRead < buffer.Length)
139+
{
140+
var read = stream.Read(buffer[totalRead..]);
141+
if (read == 0)
142+
return false;
143+
144+
totalRead += read;
145+
}
146+
147+
return true;
148+
}
149+
}

0 commit comments

Comments
 (0)