From 7a33b2c6e3deabe81dd6dc5345497e6676b41c67 Mon Sep 17 00:00:00 2001 From: nanxun Date: Fri, 14 Aug 2026 01:46:08 +0800 Subject: [PATCH] feat: accelerate ffmpeg encoding with intel gpu --- .../Services/FfmpegService.Recovery.cs | 89 +++++++++--- .../Services/FfmpegService.Runtime.cs | 7 +- .../Services/FfmpegService.Utils.cs | 130 +++++++++++++----- .../Services/FfmpegService.cs | 6 +- .../FfmpegFailureClassificationTests.cs | 87 +++++++++++- 5 files changed, 261 insertions(+), 58 deletions(-) diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Recovery.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Recovery.cs index 4388981..bc6e3a7 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Recovery.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Recovery.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Text.RegularExpressions; using LiveRecorder.Domain.Enums; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace LiveRecorder.Infrastructure.Services; @@ -53,7 +54,7 @@ public sealed partial class FfmpegService TimeSpan processRuntime) => status == RecordSessionStatus.Failed && processRuntime < StableRuntimeResetThreshold; - private async Task ResolveRecoveryVideoEncoderAsync( + private async Task ResolveVideoEncoderAsync( string ffmpegPath, bool forceSoftware, CancellationToken cancellationToken) @@ -76,11 +77,6 @@ public sealed partial class FfmpegService return _cachedRecoveryVideoEncoder; } - var candidates = new List - { - new(RecoveryVideoEncoderKind.Nvenc, null) - }; - IReadOnlyList renderDevices = []; try { @@ -93,18 +89,10 @@ public sealed partial class FfmpegService } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - _logger.LogDebug(ex, "Unable to enumerate /dev/dri render devices for recovery encoding."); + _logger.LogDebug(ex, "Unable to enumerate /dev/dri render devices for hardware encoding."); } - foreach (var devicePath in renderDevices) - { - candidates.Add(new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, devicePath)); - } - - foreach (var devicePath in renderDevices) - { - candidates.Add(new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Vaapi, devicePath)); - } + var candidates = BuildIntelVideoEncoderCandidates(renderDevices); foreach (var candidate in candidates) { @@ -113,16 +101,21 @@ public sealed partial class FfmpegService _cachedRecoveryVideoEncoder = candidate; _hasProbedRecoveryVideoEncoder = true; _logger.LogInformation( - "Recovery video encoder probe selected {EncoderKind} using device {DevicePath}", + "Video encoder probe selected {EncoderKind} using device {DevicePath}", candidate.Kind, candidate.DevicePath ?? "default"); + await WriteVideoEncoderSelectionLogAsync(candidate, null, cancellationToken); return candidate; } } _cachedRecoveryVideoEncoder = RecoveryVideoEncoderSelection.Software; _hasProbedRecoveryVideoEncoder = true; - _logger.LogInformation("No usable hardware recovery encoder was detected; libx264 will be used."); + _logger.LogInformation("No usable Intel hardware video encoder was detected; libx264 will be used."); + await WriteVideoEncoderSelectionLogAsync( + _cachedRecoveryVideoEncoder, + "No usable QSV or VAAPI encoder was detected.", + cancellationToken); return _cachedRecoveryVideoEncoder; } finally @@ -190,7 +183,63 @@ public sealed partial class FfmpegService } } - private void DisableRecoveryVideoEncoder(RecoveryVideoEncoderSelection encoder) + internal static IReadOnlyList BuildIntelVideoEncoderCandidates( + IReadOnlyList renderDevices) => + renderDevices + .Select(devicePath => new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, devicePath)) + .Concat(renderDevices.Select(devicePath => + new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Vaapi, devicePath))) + .ToArray(); + + private async Task WriteVideoEncoderSelectionLogAsync( + RecoveryVideoEncoderSelection encoder, + string? reason, + CancellationToken cancellationToken) + { + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var logService = scope.ServiceProvider.GetRequiredService(); + await logService.WriteAsync( + Domain.Enums.SystemLogLevel.Info, + "FFmpeg", + encoder.Kind == RecoveryVideoEncoderKind.Software + ? "FFmpeg hardware encoding is unavailable; using libx264." + : $"FFmpeg hardware encoding selected {encoder.Kind}.", + $"encoder={encoder.Kind}; device={encoder.DevicePath ?? "default"}; reason={reason ?? "probe succeeded"}", + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Unable to persist the FFmpeg encoder selection log."); + } + } + + private async Task WriteHardwareFallbackLogAsync( + Guid recordTaskId, + RecoveryVideoEncoderSelection encoder, + string error, + CancellationToken cancellationToken) + { + try + { + using var scope = _serviceScopeFactory.CreateScope(); + var logService = scope.ServiceProvider.GetRequiredService(); + await logService.WriteAsync( + Domain.Enums.SystemLogLevel.Warning, + "FFmpeg", + $"Hardware encoder {encoder.Kind} failed; retrying once with libx264.", + $"encoder={encoder.Kind}; device={encoder.DevicePath ?? "default"}; error={error}", + recordTaskId: recordTaskId, + cancellationToken: cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Unable to persist the FFmpeg hardware fallback log."); + } + } + + private void DisableVideoEncoder(RecoveryVideoEncoderSelection encoder) { if (encoder.Kind == RecoveryVideoEncoderKind.Software || _cachedRecoveryVideoEncoder != encoder) @@ -201,7 +250,7 @@ public sealed partial class FfmpegService _cachedRecoveryVideoEncoder = RecoveryVideoEncoderSelection.Software; _hasProbedRecoveryVideoEncoder = true; _logger.LogWarning( - "Recovery video encoder {EncoderKind} failed with a live input and was disabled until the service restarts.", + "Video encoder {EncoderKind} failed and was disabled until the service restarts.", encoder.Kind); } diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs index 22ecac7..63667d4 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Runtime.cs @@ -71,7 +71,6 @@ public sealed partial class FfmpegService } if (!runtime.HasOpenedFirstSegment && - runtime.InputOptionProfile == FfmpegInputOptionProfile.TimestampTranscode && runtime.RecoveryVideoEncoder.Kind != RecoveryVideoEncoderKind.Software && IsHardwareEncoderFailureLine(line)) { @@ -223,7 +222,11 @@ public sealed partial class FfmpegService line.Contains("Device creation failed", StringComparison.OrdinalIgnoreCase) || line.Contains("No VA display found", StringComparison.OrdinalIgnoreCase) || line.Contains("Failed to initialise VAAPI connection", StringComparison.OrdinalIgnoreCase) || + line.Contains("Failed to create a VAAPI device", StringComparison.OrdinalIgnoreCase) || + line.Contains("Failed to create QSV device", StringComparison.OrdinalIgnoreCase) || line.Contains("Error initializing an internal MFX session", StringComparison.OrdinalIgnoreCase) || + line.Contains("Error creating a MFX session", StringComparison.OrdinalIgnoreCase) || + line.Contains("Failed to upload frame", StringComparison.OrdinalIgnoreCase) || line.Contains("Error initializing output stream", StringComparison.OrdinalIgnoreCase) || line.Contains("Impossible to convert between the formats", StringComparison.OrdinalIgnoreCase) || line.Contains("A hardware device reference is required", StringComparison.OrdinalIgnoreCase); @@ -810,7 +813,7 @@ public sealed partial class FfmpegService session.Id, currentTask.Id); - DisableRecoveryVideoEncoder(runtime.RecoveryVideoEncoder); + DisableVideoEncoder(runtime.RecoveryVideoEncoder); var retryStream = new StreamUrlResult( runtime.SelectedQuality, diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs index 6e88ce3..d1ff7b1 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.Utils.cs @@ -174,8 +174,10 @@ public sealed partial class FfmpegService async Task RunFinalizeAttemptAsync( Mp4FinalizeStrategy strategy, string stage, - string detail) + string detail, + RecoveryVideoEncoderSelection? videoEncoder = null) { + videoEncoder ??= RecoveryVideoEncoderSelection.Software; if (File.Exists(tempPath)) { File.Delete(tempPath); @@ -212,7 +214,8 @@ public sealed partial class FfmpegService concatInputPath ?? normalizedSourcePaths[0], tempPath, strategy, - concatInputPath is not null)) + concatInputPath is not null, + videoEncoder)) { finalizeProcess.StartInfo.ArgumentList.Add(argument); } @@ -367,10 +370,25 @@ public sealed partial class FfmpegService if (!string.IsNullOrWhiteSpace(finalizationError) && IsRepairableMp4FinalizeError(finalizationError)) { + var videoEncoder = await ResolveVideoEncoderAsync(ffmpegPath, forceSoftware: false, cancellationToken); var repairError = await RunFinalizeAttemptAsync( Mp4FinalizeStrategy.RepairTranscode, "Repairing MP4", - $"Repairing stream metadata for {Path.GetFileName(targetPath)}"); + $"Repairing stream metadata for {Path.GetFileName(targetPath)} with {videoEncoder.Kind}", + videoEncoder); + + if (!string.IsNullOrWhiteSpace(repairError) && + videoEncoder.Kind != RecoveryVideoEncoderKind.Software && + IsHardwareEncoderFailureLine(repairError)) + { + DisableVideoEncoder(videoEncoder); + await WriteHardwareFallbackLogAsync(recordTaskId, videoEncoder, repairError, cancellationToken); + repairError = await RunFinalizeAttemptAsync( + Mp4FinalizeStrategy.RepairTranscode, + "Repairing MP4", + $"Hardware encoding failed; retrying {Path.GetFileName(targetPath)} with libx264", + RecoveryVideoEncoderSelection.Software); + } finalizationError = string.IsNullOrWhiteSpace(repairError) ? null @@ -615,8 +633,10 @@ public sealed partial class FfmpegService string sourcePath, string targetPath, Mp4FinalizeStrategy strategy, - bool useConcatDemuxer = false) + bool useConcatDemuxer = false, + RecoveryVideoEncoderSelection? videoEncoder = null) { + videoEncoder ??= RecoveryVideoEncoderSelection.Software; var arguments = new List { "-hide_banner", @@ -639,6 +659,11 @@ public sealed partial class FfmpegService arguments.AddRange(["-f", "concat", "-safe", "0"]); } + if (strategy == Mp4FinalizeStrategy.RepairTranscode) + { + AddRecoveryEncoderDeviceArguments(arguments, videoEncoder); + } + arguments.AddRange( [ "-i", sourcePath, @@ -652,14 +677,10 @@ public sealed partial class FfmpegService if (strategy == Mp4FinalizeStrategy.RepairTranscode) { - arguments.AddRange( - [ - "-c:v", "libx264", - "-preset", "veryfast", - "-crf", "23", - "-c:a", "aac", - "-b:a", "128k" - ]); + arguments.AddRange(videoEncoder.Kind == RecoveryVideoEncoderKind.Software + ? BuildSoftwareVideoCodecArguments() + : BuildRecoveryVideoCodecArguments(videoEncoder)); + arguments.AddRange(["-c:a", "aac", "-b:a", "128k"]); } else { @@ -700,8 +721,27 @@ public sealed partial class FfmpegService var temporaryPath = $"{targetPath}.repairing"; string? lastError = null; - foreach (var strategy in new[] { Mp4FinalizeStrategy.StreamCopy, Mp4FinalizeStrategy.RepairTranscode }) + var videoEncoder = await ResolveVideoEncoderAsync(ffmpegPath, forceSoftware: false, cancellationToken); + var attempts = new List<(Mp4FinalizeStrategy Strategy, RecoveryVideoEncoderSelection Encoder)> { + (Mp4FinalizeStrategy.StreamCopy, RecoveryVideoEncoderSelection.Software), + (Mp4FinalizeStrategy.RepairTranscode, videoEncoder) + }; + if (videoEncoder.Kind != RecoveryVideoEncoderKind.Software) + { + attempts.Add((Mp4FinalizeStrategy.RepairTranscode, RecoveryVideoEncoderSelection.Software)); + } + + foreach (var attempt in attempts) + { + if (attempt.Strategy == Mp4FinalizeStrategy.RepairTranscode && + attempt.Encoder.Kind == RecoveryVideoEncoderKind.Software && + videoEncoder.Kind != RecoveryVideoEncoderKind.Software && + !IsHardwareEncoderFailureLine(lastError ?? string.Empty)) + { + break; + } + if (File.Exists(temporaryPath)) { File.Delete(temporaryPath); @@ -720,7 +760,11 @@ public sealed partial class FfmpegService } }; var processStarted = false; - foreach (var argument in BuildMp4FinalizeArgumentList(sourcePath, temporaryPath, strategy)) + foreach (var argument in BuildMp4FinalizeArgumentList( + sourcePath, + temporaryPath, + attempt.Strategy, + videoEncoder: attempt.Encoder)) { process.StartInfo.ArgumentList.Add(argument); } @@ -752,6 +796,15 @@ public sealed partial class FfmpegService lastError = validation.ErrorMessage; } + + if (attempt.Strategy == Mp4FinalizeStrategy.RepairTranscode && + attempt.Encoder.Kind != RecoveryVideoEncoderKind.Software && + !string.IsNullOrWhiteSpace(lastError) && + IsHardwareEncoderFailureLine(lastError)) + { + DisableVideoEncoder(attempt.Encoder); + await WriteHardwareFallbackLogAsync(recordTaskId, attempt.Encoder, lastError, cancellationToken); + } } catch (OperationCanceledException) { @@ -885,7 +938,9 @@ public sealed partial class FfmpegService AddNativeHttpInputHeaders(arguments, streamUrl, inputHeaders); } - if (inputOptionProfile == FfmpegInputOptionProfile.TimestampTranscode) + if (recoveryVideoEncoder.Kind != RecoveryVideoEncoderKind.Software && + (inputOptionProfile == FfmpegInputOptionProfile.TimestampTranscode || + recordingTemplate == RecordingTemplateType.BalancedMp4)) { AddRecoveryEncoderDeviceArguments(arguments, recoveryVideoEncoder); } @@ -964,15 +1019,7 @@ public sealed partial class FfmpegService { return recordingTemplate switch { - RecordingTemplateType.BalancedMp4 => - [ - "-c:v", "libx264", - "-preset", "veryfast", - "-crf", "23", - "-c:a", "aac", - "-af", "aresample=async=1:first_pts=0", - "-b:a", "128k" - ], + RecordingTemplateType.BalancedMp4 => BuildBalancedMp4CodecArguments(recoveryVideoEncoder, repairTimestamps: true), RecordingTemplateType.ArchiveTs => [ "-map", "0", @@ -995,14 +1042,7 @@ public sealed partial class FfmpegService return recordingTemplate switch { - RecordingTemplateType.BalancedMp4 => - [ - "-c:v", "libx264", - "-preset", "veryfast", - "-crf", "23", - "-c:a", "aac", - "-b:a", "128k" - ], + RecordingTemplateType.BalancedMp4 => BuildBalancedMp4CodecArguments(recoveryVideoEncoder, repairTimestamps: false), RecordingTemplateType.ArchiveTs => [ "-map", "0", @@ -1015,6 +1055,30 @@ public sealed partial class FfmpegService }; } + private static IReadOnlyList BuildBalancedMp4CodecArguments( + RecoveryVideoEncoderSelection videoEncoder, + bool repairTimestamps) + { + var arguments = new List(); + arguments.AddRange(videoEncoder.Kind == RecoveryVideoEncoderKind.Software + ? BuildSoftwareVideoCodecArguments() + : BuildRecoveryVideoCodecArguments(videoEncoder)); + arguments.AddRange(["-c:a", "aac"]); + if (repairTimestamps) + { + arguments.AddRange(["-af", "aresample=async=1:first_pts=0"]); + } + arguments.AddRange(["-b:a", "128k"]); + return arguments; + } + + private static IReadOnlyList BuildSoftwareVideoCodecArguments() => + [ + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "23" + ]; + internal static void AddRecoveryEncoderDeviceArguments( ICollection arguments, RecoveryVideoEncoderSelection encoder) @@ -1051,7 +1115,7 @@ public sealed partial class FfmpegService ], RecoveryVideoEncoderKind.Qsv => [ - "-vf", "settb=AVTB,setpts=PTS-STARTPTS,format=nv12,hwupload=extra_hw_frames=64", + "-vf", "settb=AVTB,setpts=PTS-STARTPTS,format=nv12", "-c:v", "h264_qsv", "-preset", "veryfast", "-global_quality", "23", diff --git a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs index 086927c..f8208f1 100644 --- a/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs +++ b/src/LiveRecorder.Infrastructure/Services/FfmpegService.cs @@ -246,8 +246,10 @@ public sealed partial class FfmpegService : IFfmpegService using var settingsScope = _serviceScopeFactory.CreateScope(); var settingsService = settingsScope.ServiceProvider.GetRequiredService(); var settings = await settingsService.GetAsync(cancellationToken); - var recoveryVideoEncoder = recoveryContext.InputOptionProfile == FfmpegInputOptionProfile.TimestampTranscode - ? await ResolveRecoveryVideoEncoderAsync(settings.FfmpegPath, recoveryContext.ForceSoftwareEncoder, cancellationToken) + var requiresVideoEncoding = recoveryContext.InputOptionProfile == FfmpegInputOptionProfile.TimestampTranscode || + recordingSettings.RecordingTemplate == RecordingTemplateType.BalancedMp4; + var recoveryVideoEncoder = requiresVideoEncoding + ? await ResolveVideoEncoderAsync(settings.FfmpegPath, recoveryContext.ForceSoftwareEncoder, cancellationToken) : RecoveryVideoEncoderSelection.Software; var outputPathPattern = Path.IsPathRooted(recordSession.OutputPathPattern) diff --git a/tests/LiveRecorder.Tests/FfmpegFailureClassificationTests.cs b/tests/LiveRecorder.Tests/FfmpegFailureClassificationTests.cs index ca1c311..404930d 100644 --- a/tests/LiveRecorder.Tests/FfmpegFailureClassificationTests.cs +++ b/tests/LiveRecorder.Tests/FfmpegFailureClassificationTests.cs @@ -419,6 +419,90 @@ public sealed class FfmpegFailureClassificationTests Assert.Contains("/dev/dri/renderD129", vaapiArguments); } + [Fact] + public void IntelEncoderCandidates_PreferQsvThenVaapiForEachRenderDevice() + { + var candidates = FfmpegService.BuildIntelVideoEncoderCandidates( + ["/dev/dri/renderD128", "/dev/dri/renderD129"]); + + Assert.Equal( + [ + new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, "/dev/dri/renderD128"), + new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, "/dev/dri/renderD129"), + new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Vaapi, "/dev/dri/renderD128"), + new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Vaapi, "/dev/dri/renderD129") + ], + candidates); + } + + [Fact] + public void Mp4Repair_WithQsv_UsesHardwareAndPlacesDeviceBeforeInput() + { + var arguments = FfmpegService.BuildMp4FinalizeArgumentList( + "/records/input.ts", + "/records/output.mp4", + FfmpegService.Mp4FinalizeStrategy.RepairTranscode, + videoEncoder: new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Qsv, "/dev/dri/renderD128")); + + var argumentList = arguments.ToList(); + Assert.True(argumentList.IndexOf("-qsv_device") < argumentList.IndexOf("-i")); + Assert.Contains("h264_qsv", arguments); + Assert.DoesNotContain("hwupload", string.Join(',', arguments)); + Assert.DoesNotContain("libx264", arguments); + Assert.Contains("aac", arguments); + } + + [Fact] + public void Mp4Repair_SoftwareFallback_UsesLibx264() + { + var arguments = FfmpegService.BuildMp4FinalizeArgumentList( + "/records/input.ts", + "/records/output.mp4", + FfmpegService.Mp4FinalizeStrategy.RepairTranscode, + videoEncoder: RecoveryVideoEncoderSelection.Software); + + Assert.Contains("libx264", arguments); + Assert.DoesNotContain("h264_qsv", arguments); + Assert.DoesNotContain("h264_vaapi", arguments); + Assert.DoesNotContain("-vf", arguments); + Assert.DoesNotContain("-fps_mode", arguments); + } + + [Fact] + public void BalancedMp4_WithVaapi_UsesHardwareWhileStreamCopyRemainsUnchanged() + { + var vaapi = new RecoveryVideoEncoderSelection(RecoveryVideoEncoderKind.Vaapi, "/dev/dri/renderD128"); + var balanced = FfmpegService.BuildArgumentList( + "https://cdn.example.test/live.flv", "/tmp/output.mp4", + RecordOutputFormat.Mp4, RecordSaveMode.SingleFile, 1, + RecordingTemplateType.BalancedMp4, true, 10, 30_000_000, 30, + null, "flv", "h264", FfmpegService.FfmpegInputOptionProfile.Baseline, vaapi); + var streamCopy = FfmpegService.BuildArgumentList( + "https://cdn.example.test/live.flv", "/tmp/output.mp4", + RecordOutputFormat.Mp4, RecordSaveMode.SingleFile, 1, + RecordingTemplateType.StreamCopy, true, 10, 30_000_000, 30, + null, "flv", "h264", FfmpegService.FfmpegInputOptionProfile.Baseline, vaapi); + + var balancedList = balanced.ToList(); + Assert.True(balancedList.IndexOf("-vaapi_device") < balancedList.IndexOf("-i")); + Assert.Contains("h264_vaapi", balanced); + Assert.DoesNotContain("libx264", balanced); + Assert.Contains("copy", streamCopy); + Assert.DoesNotContain("h264_vaapi", streamCopy); + Assert.DoesNotContain("libx264", streamCopy); + } + + [Theory] + [InlineData("Permission denied", false)] + [InlineData("Invalid data found when processing input", false)] + [InlineData("No VA display found for device /dev/dri/renderD128", true)] + [InlineData("Failed to create QSV device", true)] + [InlineData("Failed to upload frame: -5", true)] + public void OnlyHardwareEncoderFailures_EnableSoftwareFallback(string line, bool expected) + { + Assert.Equal(expected, FfmpegService.IsHardwareEncoderFailureLine(line)); + } + [Fact] public void TimestampTranscode_WithQsv_PlacesDeviceBeforeInputAndKeepsTimestampFilters() { @@ -443,7 +527,8 @@ public sealed class FfmpegFailureClassificationTests Assert.True(argumentList.IndexOf("-qsv_device") < argumentList.IndexOf("-i")); Assert.Contains("h264_qsv", arguments); Assert.Contains(arguments, item => item.Contains("setpts=PTS-STARTPTS", StringComparison.Ordinal)); - Assert.Contains(arguments, item => item.Contains("hwupload", StringComparison.Ordinal)); + Assert.Contains(arguments, item => item.Contains("format=nv12", StringComparison.Ordinal)); + Assert.DoesNotContain(arguments, item => item.Contains("hwupload", StringComparison.Ordinal)); Assert.Contains("aresample=async=1:first_pts=0,asetpts=PTS-STARTPTS", arguments); } }