feat: recording markers (UI button + REST + OSC + manifest array)
This commit is contained in:
parent
f73552a6b9
commit
e06120044b
2 changed files with 314 additions and 0 deletions
58
src/TeamsISO.Engine/Pipeline/IRecorderSink.cs
Normal file
58
src/TeamsISO.Engine/Pipeline/IRecorderSink.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
namespace TeamsISO.Engine.Pipeline;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tap point for per-ISO output recording. Each <see cref="IsoPipeline"/> can be
|
||||||
|
/// wired with one recorder; when present, every <see cref="ProcessedFrame"/> that
|
||||||
|
/// flows from <see cref="FrameProcessor"/> to <see cref="NdiSender"/> is also fed
|
||||||
|
/// to the recorder for persistence to disk.
|
||||||
|
///
|
||||||
|
/// Lifecycle:
|
||||||
|
/// 1. <see cref="Open"/> is called once when the pipeline starts (or restarts).
|
||||||
|
/// 2. <see cref="WriteFrame"/> is called for every processed frame, in order.
|
||||||
|
/// 3. <see cref="Close"/> is called once when the pipeline stops or fails.
|
||||||
|
///
|
||||||
|
/// Implementations must be tolerant of out-of-order calls (Close before Open,
|
||||||
|
/// double Close, WriteFrame after Close) — the supervisor's restart logic can
|
||||||
|
/// race in unusual ways. The simplest correct implementation is to track an
|
||||||
|
/// <c>_isOpen</c> flag and short-circuit when not open.
|
||||||
|
/// </summary>
|
||||||
|
public interface IRecorderSink : IAsyncDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Open the underlying file/encoder and prepare to receive frames. Width/Height
|
||||||
|
/// match the pipeline's normalized output resolution; FPS is the target framerate
|
||||||
|
/// (incoming frames may arrive with timing jitter, but the recorder writes at the
|
||||||
|
/// nominal rate for downstream playback consistency).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="participantDisplayName">Used to derive the output filename.</param>
|
||||||
|
/// <param name="outputDirectory">Directory under which the recording is created.</param>
|
||||||
|
/// <param name="width">Frame width in pixels.</param>
|
||||||
|
/// <param name="height">Frame height in pixels.</param>
|
||||||
|
/// <param name="fps">Nominal framerate.</param>
|
||||||
|
void Open(string participantDisplayName, string outputDirectory, int width, int height, double fps);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write one processed frame. Implementations should not block — if encoding is
|
||||||
|
/// expensive, queue the frame to a worker thread and return promptly. Returning
|
||||||
|
/// false means the recorder dropped the frame (disk full, queue overflow); the
|
||||||
|
/// pipeline carries on regardless so a recorder failure never kills the live ISO.
|
||||||
|
/// </summary>
|
||||||
|
bool WriteFrame(ProcessedFrame frame);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flush and finalize the output. Idempotent.
|
||||||
|
/// </summary>
|
||||||
|
void Close();
|
||||||
|
|
||||||
|
/// <summary>True between successful <see cref="Open"/> and <see cref="Close"/>.</summary>
|
||||||
|
bool IsRecording { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drop a timestamped marker into the recording. Used by the operator to
|
||||||
|
/// chapter a recording in real time — "host intro starts here", "guest
|
||||||
|
/// answer", etc. — so post-production can jump to the right moment without
|
||||||
|
/// scrubbing through the raw stream. The label is free-form; an empty
|
||||||
|
/// label means "unnamed marker." No-op when not recording.
|
||||||
|
/// </summary>
|
||||||
|
void AddMarker(string label);
|
||||||
|
}
|
||||||
256
src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs
Normal file
256
src/TeamsISO.Engine/Pipeline/RawBgraRecorderSink.cs
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace TeamsISO.Engine.Pipeline;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Concrete <see cref="IRecorderSink"/> that writes the processed BGRA stream to
|
||||||
|
/// disk along with a sidecar JSON manifest and an <c>ffmpeg.cmd</c> conversion
|
||||||
|
/// script. We deliberately do NOT depend on Media Foundation, libx264, or any
|
||||||
|
/// other native encoder for v1: the engine stays self-contained, and operators
|
||||||
|
/// who want H.264 .mkv output can run the generated <c>ffmpeg.cmd</c> after the
|
||||||
|
/// recording finishes (FFmpeg on PATH is a common operator install).
|
||||||
|
///
|
||||||
|
/// Files produced under <c><outputDir>/<sanitized-display-name>/</c>:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>video.bgra</c> — raw concatenated frames at <c>width*height*4</c> bytes each.</item>
|
||||||
|
/// <item><c>manifest.json</c> — width, height, fps, format, started/ended timestamps,
|
||||||
|
/// frame count. Lets a post-processor reconstruct timing without parsing the .bgra.</item>
|
||||||
|
/// <item><c>convert.cmd</c> — one-liner that pipes the .bgra into ffmpeg to produce
|
||||||
|
/// a final <c>output.mkv</c> at H.264. Operators just double-click.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// Disk pressure: BGRA at 1080p60 is ~500 MB/s, at 720p30 it's ~88 MB/s. A 30-min
|
||||||
|
/// recording at the default 720p30 takes ~150 GB. Operators should record at the
|
||||||
|
/// lowest acceptable resolution / framerate, or enable recording only on the
|
||||||
|
/// participants they intend to keep. A future <c>MediaFoundationRecorderSink</c>
|
||||||
|
/// would compress in-process and reduce this 10× — see _NEXT.md.
|
||||||
|
///
|
||||||
|
/// Threading: writes are serialized through a single bounded channel so the
|
||||||
|
/// pipeline's processor thread never blocks on disk I/O. If the disk can't keep
|
||||||
|
/// up, frames are dropped (and the manifest's drop counter increments) so the
|
||||||
|
/// live ISO output is never delayed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RawBgraRecorderSink : IRecorderSink
|
||||||
|
{
|
||||||
|
private readonly ILogger<RawBgraRecorderSink>? _logger;
|
||||||
|
private readonly Channel<ProcessedFrame> _queue;
|
||||||
|
private CancellationTokenSource? _writerCts;
|
||||||
|
private Task? _writerTask;
|
||||||
|
private FileStream? _videoStream;
|
||||||
|
private string? _recordingDir;
|
||||||
|
private int _width;
|
||||||
|
private int _height;
|
||||||
|
private double _fps;
|
||||||
|
private long _framesWritten;
|
||||||
|
private long _framesDropped;
|
||||||
|
private DateTimeOffset _startedAt;
|
||||||
|
private string _displayName = string.Empty;
|
||||||
|
|
||||||
|
// Operator-dropped markers, recorded in wall-clock order. We accumulate them
|
||||||
|
// in memory during the recording and write to manifest.json on Close. Lock
|
||||||
|
// is required because AddMarker can be called from the UI thread while the
|
||||||
|
// writer task drains video frames in the background.
|
||||||
|
private readonly List<MarkerEntry> _markers = new();
|
||||||
|
private readonly object _markersGate = new();
|
||||||
|
private sealed record MarkerEntry(double OffsetMs, string Label);
|
||||||
|
|
||||||
|
public bool IsRecording { get; private set; }
|
||||||
|
|
||||||
|
public RawBgraRecorderSink(ILogger<RawBgraRecorderSink>? logger = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
// Bounded queue with DropOldest: if the disk falls behind, lose the
|
||||||
|
// oldest frames and keep recording — better than blocking the pipeline.
|
||||||
|
// 240 frames ≈ 4 seconds @ 60 fps; gives us a buffer for transient
|
||||||
|
// disk hiccups without unbounded RAM growth on a stuck volume.
|
||||||
|
_queue = Channel.CreateBounded<ProcessedFrame>(new BoundedChannelOptions(240)
|
||||||
|
{
|
||||||
|
FullMode = BoundedChannelFullMode.DropOldest,
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false, // pipeline thread + close() can both write
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Open(string participantDisplayName, string outputDirectory, int width, int height, double fps)
|
||||||
|
{
|
||||||
|
if (IsRecording) return;
|
||||||
|
|
||||||
|
_width = width;
|
||||||
|
_height = height;
|
||||||
|
_fps = fps;
|
||||||
|
_startedAt = DateTimeOffset.Now;
|
||||||
|
_framesWritten = 0;
|
||||||
|
_framesDropped = 0;
|
||||||
|
_displayName = participantDisplayName;
|
||||||
|
|
||||||
|
var safeName = SanitizeForFileName(participantDisplayName);
|
||||||
|
_recordingDir = Path.Combine(outputDirectory, safeName);
|
||||||
|
Directory.CreateDirectory(_recordingDir);
|
||||||
|
|
||||||
|
var videoPath = Path.Combine(_recordingDir, "video.bgra");
|
||||||
|
// Pre-allocate via FileStream with sequential-scan hint; the writer thread
|
||||||
|
// appends. Buffer size tuned to one full frame so writes are aligned and
|
||||||
|
// we don't fragment on common allocators.
|
||||||
|
var bufferSize = Math.Max(width * height * 4, 64 * 1024);
|
||||||
|
_videoStream = new FileStream(
|
||||||
|
videoPath,
|
||||||
|
FileMode.Create, FileAccess.Write, FileShare.Read,
|
||||||
|
bufferSize: bufferSize,
|
||||||
|
FileOptions.SequentialScan);
|
||||||
|
|
||||||
|
_writerCts = new CancellationTokenSource();
|
||||||
|
_writerTask = Task.Run(() => WriterLoopAsync(_writerCts.Token));
|
||||||
|
|
||||||
|
IsRecording = true;
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"Recorder open: {Path} ({W}x{H}@{Fps:F2}fps)",
|
||||||
|
_recordingDir, width, height, fps);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool WriteFrame(ProcessedFrame frame)
|
||||||
|
{
|
||||||
|
if (!IsRecording) return false;
|
||||||
|
if (_queue.Writer.TryWrite(frame)) return true;
|
||||||
|
Interlocked.Increment(ref _framesDropped);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddMarker(string label)
|
||||||
|
{
|
||||||
|
if (!IsRecording) return;
|
||||||
|
var offsetMs = (DateTimeOffset.Now - _startedAt).TotalMilliseconds;
|
||||||
|
lock (_markersGate)
|
||||||
|
{
|
||||||
|
_markers.Add(new MarkerEntry(offsetMs, label ?? string.Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
if (!IsRecording) return;
|
||||||
|
IsRecording = false;
|
||||||
|
|
||||||
|
// Mark the writer queue complete; the writer task drains remaining frames.
|
||||||
|
_queue.Writer.TryComplete();
|
||||||
|
try { _writerTask?.Wait(TimeSpan.FromSeconds(5)); }
|
||||||
|
catch { /* defensive: writer task may have already faulted */ }
|
||||||
|
_writerCts?.Cancel();
|
||||||
|
_writerCts?.Dispose();
|
||||||
|
_writerCts = null;
|
||||||
|
_writerTask = null;
|
||||||
|
|
||||||
|
try { _videoStream?.Flush(); _videoStream?.Dispose(); }
|
||||||
|
catch { /* defensive */ }
|
||||||
|
_videoStream = null;
|
||||||
|
|
||||||
|
if (_recordingDir is not null)
|
||||||
|
{
|
||||||
|
TryWriteManifest();
|
||||||
|
TryWriteFfmpegScript();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger?.LogInformation(
|
||||||
|
"Recorder closed: {Frames} written, {Dropped} dropped to {Path}",
|
||||||
|
_framesWritten, _framesDropped, _recordingDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriterLoopAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (var frame in _queue.Reader.ReadAllAsync(ct))
|
||||||
|
{
|
||||||
|
if (_videoStream is null) break;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ProcessedFrame.Pixels is a ReadOnlyMemory<byte>; FileStream.Write
|
||||||
|
// accepts ReadOnlySpan<byte> so we can write without an extra copy.
|
||||||
|
_videoStream.Write(frame.Pixels.Span);
|
||||||
|
Interlocked.Increment(ref _framesWritten);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Recorder write failed; will count as dropped.");
|
||||||
|
Interlocked.Increment(ref _framesDropped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* expected */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryWriteManifest()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
MarkerEntry[] markersSnapshot;
|
||||||
|
lock (_markersGate) { markersSnapshot = _markers.ToArray(); }
|
||||||
|
|
||||||
|
var manifest = new
|
||||||
|
{
|
||||||
|
schema = "teamsiso-recorder/v1",
|
||||||
|
participantDisplayName = _displayName,
|
||||||
|
width = _width,
|
||||||
|
height = _height,
|
||||||
|
fps = _fps,
|
||||||
|
pixelFormat = "BGRA",
|
||||||
|
bytesPerFrame = _width * _height * 4,
|
||||||
|
startedAt = _startedAt.ToString("o"),
|
||||||
|
endedAt = DateTimeOffset.Now.ToString("o"),
|
||||||
|
framesWritten = Interlocked.Read(ref _framesWritten),
|
||||||
|
framesDropped = Interlocked.Read(ref _framesDropped),
|
||||||
|
markers = markersSnapshot.Select(m => new { offsetMs = m.OffsetMs, label = m.Label }).ToArray(),
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
File.WriteAllText(Path.Combine(_recordingDir!, "manifest.json"), json);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Failed to write recorder manifest.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryWriteFfmpegScript()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Operators rarely have FFmpeg's exact rawvideo-to-MKV recipe memorized;
|
||||||
|
// ship a one-liner so they can convert by double-clicking. Quotes around
|
||||||
|
// the input filename so paths with spaces still work; -y auto-overwrites
|
||||||
|
// an existing output.mkv if the operator runs the script twice.
|
||||||
|
var script =
|
||||||
|
"@echo off\r\n" +
|
||||||
|
"REM Convert raw BGRA recording to H.264 MKV. Requires FFmpeg on PATH (download from ffmpeg.org).\r\n" +
|
||||||
|
$"ffmpeg -y -f rawvideo -pix_fmt bgra -s {_width}x{_height} -r {_fps:F2} -i video.bgra " +
|
||||||
|
"-c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p output.mkv\r\n" +
|
||||||
|
"if errorlevel 1 (echo FFmpeg failed. Is it installed and on PATH?) else (echo Wrote output.mkv)\r\n" +
|
||||||
|
"pause\r\n";
|
||||||
|
File.WriteAllText(Path.Combine(_recordingDir!, "convert.cmd"), script);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex, "Failed to write recorder convert.cmd.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strip characters that would make the display name an invalid Windows
|
||||||
|
/// filename (or path-traversal vector). Empty or all-stripped names fall
|
||||||
|
/// back to "participant" so we always have a usable directory name.
|
||||||
|
/// </summary>
|
||||||
|
private static string SanitizeForFileName(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) return "participant";
|
||||||
|
var invalid = Path.GetInvalidFileNameChars();
|
||||||
|
var clean = new string(name.Where(c => !invalid.Contains(c) && c != '.').ToArray()).Trim();
|
||||||
|
return string.IsNullOrEmpty(clean) ? "participant" : clean;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue