// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Microsoft.Testing.Extensions.VideoRecorder.Resources;
using Microsoft.Testing.Platform.Helpers;
namespace Microsoft.Testing.Extensions.VideoRecorder;
///
/// Captures the screen by driving a single long-lived ffmpeg process that writes the recording as a
/// rolling sequence of short, independently playable segments (the ffmpeg segment muxer).
/// Recording runs continuously for the whole session, which removes the start/stop race that a
/// per-test recorder suffers (by the time an asynchronous data consumer observes that a test
/// started, the test may already be finishing). Per-test clips and the chaptered session video are
/// produced afterwards by losslessly concatenating (-c copy) the relevant segments.
///
///
/// is best-effort and never throws — if ffmpeg is unavailable or the launch
/// fails, it warns and the recorder simply produces nothing.
///
[UnsupportedOSPlatform("browser")]
[UnsupportedOSPlatform("ios")]
[UnsupportedOSPlatform("tvos")]
[UnsupportedOSPlatform("wasi")]
internal sealed partial class FfmpegVideoRecorder : IVideoRecorder
{
private static readonly TimeSpan StopTimeout = TimeSpan.FromSeconds(15);
private readonly VideoRecorderOptions _options;
private readonly string _outputDirectory;
private readonly IClock _clock;
private readonly Action? _log;
private readonly Action? _warn;
private readonly ConcurrentQueue _recentFfmpegOutput = new();
#if NET9_0_OR_GREATER
private readonly Lock _gate = new();
#else
private readonly object _gate = new();
#endif
private Process? _process;
private string? _segmentListPath;
public FfmpegVideoRecorder(VideoRecorderOptions options, string outputDirectory, IClock clock, Action? log, Action? warn)
{
_options = options;
_outputDirectory = outputDirectory;
_clock = clock;
_log = log;
_warn = warn;
FfmpegPath = FindFfmpeg(options.FfmpegPath);
}
public bool IsAvailable => FfmpegPath is not null;
///
/// Gets the path to the resolved ffmpeg executable, or if none was found.
///
public string? FfmpegPath { get; }
///
/// Gets the wall-clock time at which continuous recording started, used to map a test's
/// timestamps to an offset within the recording. until
/// succeeds.
///
public DateTimeOffset? RecordingStartUtc { get; private set; }
///
/// Gets the directory the segments are written to, or if recording never
/// started.
///
public string? SegmentDirectory { get; private set; }
///
/// Gets the file extension used for produced videos (without the dot).
///
public string SegmentExtension { get; private set; } = "mp4";
///
/// Starts the continuous segmented recording. Best-effort: never throws.
///
public void Start()
{
if (FfmpegPath is null)
{
_warn?.Invoke(VideoRecorderResources.FfmpegNotFound);
return;
}
lock (_gate)
{
if (_process is { HasExited: false })
{
return;
}
Process? process = null;
try
{
SegmentExtension = _options.Format == VideoRecorderFormat.WebMVp9 ? "webm" : "mp4";
string segmentDirectory = Path.Combine(_outputDirectory, "segments_" + Guid.NewGuid().ToString("N").Substring(0, 8));
Directory.CreateDirectory(segmentDirectory);
string segmentListPath = Path.Combine(segmentDirectory, "segments.csv");
string arguments = BuildSegmentArguments(segmentDirectory, segmentListPath);
var startInfo = new ProcessStartInfo(FfmpegPath, arguments)
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
_log?.Invoke($"Starting screen recording: \"{FfmpegPath}\" {arguments}");
ClearRecentFfmpegOutput();
process = new Process { StartInfo = startInfo };
process.OutputDataReceived += OnFfmpegOutput;
process.ErrorDataReceived += OnFfmpegOutput;
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
_process = process;
SegmentDirectory = segmentDirectory;
_segmentListPath = segmentListPath;
RecordingStartUtc = _clock.UtcNow;
}
catch (Exception ex)
{
// Best-effort: anything from creating the output directory, building arguments, or
// launching ffmpeg simply means no recording. Never surface as a test failure.
_warn?.Invoke(string.Format(CultureInfo.CurrentCulture, VideoRecorderResources.FailedToStartRecording, ex.Message));
if (process is not null)
{
TryKill(process);
DetachAndDispose(process);
}
_process = null;
SegmentDirectory = null;
_segmentListPath = null;
RecordingStartUtc = null;
}
}
}
///
/// Stops the continuous recording, asking ffmpeg to finalize the current segment cleanly.
///
public async Task StopAsync(CancellationToken cancellationToken = default)
{
Process? process;
lock (_gate)
{
process = _process;
_process = null;
}
if (process is null)
{
return;
}
try
{
if (!process.HasExited)
{
try
{
await process.StandardInput.WriteLineAsync("q").ConfigureAwait(false);
#if NET
await process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false);
#else
await process.StandardInput.FlushAsync().ConfigureAwait(false);
#endif
}
catch (IOException)
{
// stdin already closed; fall through to wait/kill.
}
if (!await WaitForExitAsync(process, StopTimeout, cancellationToken).ConfigureAwait(false))
{
_warn?.Invoke(VideoRecorderResources.StopTimeoutKilled);
TryKill(process);
}
}
}
finally
{
DetachAndDispose(process);
}
}
///
/// Deletes the given finalized segments from disk (used by the rolling-buffer pruning to bound
/// disk usage on long runs). Best-effort.
///
public static void PruneSegments(IEnumerable segments)
{
foreach (VideoSegment segment in segments)
{
try
{
if (File.Exists(segment.Path))
{
File.Delete(segment.Path);
}
}
catch (Exception)
{
// Best effort.
}
}
}
///
/// Losslessly concatenates the given segments (and optional chapter metadata) into a single
/// output file using a short-lived ffmpeg concat invocation. Returns the output path on
/// success, or if nothing usable was produced.
///
public async Task ConcatAsync(IReadOnlyList segments, string outputFileName, string? ffmetadataPath, CancellationToken cancellationToken)
{
if (FfmpegPath is null || segments.Count == 0 || SegmentDirectory is null)
{
return null;
}
string outputPath = Path.Combine(_outputDirectory, outputFileName);
string listPath = Path.Combine(SegmentDirectory, "concat_" + Guid.NewGuid().ToString("N").Substring(0, 8) + ".txt");
try
{
var builder = new StringBuilder();
foreach (VideoSegment segment in segments)
{
// The concat demuxer requires single quotes around the path with embedded quotes escaped.
builder.Append("file '").Append(segment.Path.Replace("'", @"'\''")).AppendLine("'");
}
#if NET
await File.WriteAllTextAsync(listPath, builder.ToString(), cancellationToken).ConfigureAwait(false);
#else
using (var listWriter = new StreamWriter(new FileStream(listPath, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true)))
{
await listWriter.WriteAsync(builder.ToString()).ConfigureAwait(false);
}
#endif
var arguments = new StringBuilder();
arguments.Append("-y -f concat -safe 0 -i \"").Append(listPath).Append('"');
if (ffmetadataPath is not null)
{
arguments.Append(" -i \"").Append(ffmetadataPath).Append("\" -map 0 -map_chapters 1");
}
arguments.Append(" -c copy \"").Append(outputPath).Append('"');
if (!await RunFfmpegAsync(arguments.ToString(), cancellationToken).ConfigureAwait(false))
{
return null;
}
}
catch (Exception ex)
{
_log?.Invoke($"Failed to concatenate segments into '{outputFileName}': {ex.Message}");
return null;
}
finally
{
try
{
if (File.Exists(listPath))
{
File.Delete(listPath);
}
}
catch (Exception)
{
// Best effort.
}
}
return File.Exists(outputPath) && new FileInfo(outputPath).Length > 0
? outputPath
: null;
}
}