feat: accelerate ffmpeg encoding with intel gpu

This commit is contained in:
2026-08-14 01:46:08 +08:00
parent 83b5ea042d
commit 7a33b2c6e3
5 changed files with 261 additions and 58 deletions
@@ -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<RecoveryVideoEncoderSelection> ResolveRecoveryVideoEncoderAsync(
private async Task<RecoveryVideoEncoderSelection> ResolveVideoEncoderAsync(
string ffmpegPath,
bool forceSoftware,
CancellationToken cancellationToken)
@@ -76,11 +77,6 @@ public sealed partial class FfmpegService
return _cachedRecoveryVideoEncoder;
}
var candidates = new List<RecoveryVideoEncoderSelection>
{
new(RecoveryVideoEncoderKind.Nvenc, null)
};
IReadOnlyList<string> 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<RecoveryVideoEncoderSelection> BuildIntelVideoEncoderCandidates(
IReadOnlyList<string> 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<LiveRecorder.Application.Abstractions.Logging.ISystemLogService>();
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<LiveRecorder.Application.Abstractions.Logging.ISystemLogService>();
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);
}
@@ -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,
@@ -174,8 +174,10 @@ public sealed partial class FfmpegService
async Task<string?> 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<string>
{
"-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<string> BuildBalancedMp4CodecArguments(
RecoveryVideoEncoderSelection videoEncoder,
bool repairTimestamps)
{
var arguments = new List<string>();
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<string> BuildSoftwareVideoCodecArguments() =>
[
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23"
];
internal static void AddRecoveryEncoderDeviceArguments(
ICollection<string> 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",
@@ -246,8 +246,10 @@ public sealed partial class FfmpegService : IFfmpegService
using var settingsScope = _serviceScopeFactory.CreateScope();
var settingsService = settingsScope.ServiceProvider.GetRequiredService<ISystemSettingsService>();
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)
@@ -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);
}
}