From 3e3bfbd5f5fcb37b5d8136ae7d75bcc05012a481 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:37:40 -0600 Subject: [PATCH] use heuristic to work around some qsv av desync cases (#2829) * check for multiple h264 profiles using qsv decoding * fix build * update changelog * pass cancellation token --- CHANGELOG.md | 2 + .../FFmpeg/FFmpegLibraryProcessService.cs | 30 ++++++++- .../Metadata/ILocalStatisticsProvider.cs | 5 ++ ErsatzTV.FFmpeg/MediaStream.cs | 2 + .../Pipeline/QsvPipelineBuilder.cs | 8 +++ .../Metadata/LocalStatisticsProvider.cs | 62 +++++++++++++++++++ 6 files changed, 106 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36df8d31b..3172de29b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time - Fix Trakt list sync +- Fix some cases of QSV audio/video desync when *not* seeking by using software decode + - This only applies to content that *might* be problematic (using a heuristic) ## [26.2.0] - 2026-02-02 ### Added diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 22584711f..7abbf7747 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -233,6 +233,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService scanKind = await ProbeScanKind(ffmpegPath, videoVersion.MediaItem, cancellationToken); } + HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings); + + // QSV may have sync issues with h264 files that have multiple profiles + // check and flag here so software decoding can be used if needed + bool hasMultipleProfiles = false; + if (hwAccel is HardwareAccelerationMode.Qsv && videoStream.Codec is VideoFormat.H264) + { + hasMultipleProfiles = await ProbeHasMultipleProfiles(ffmpegPath, videoVersion.MediaItem, cancellationToken); + } + var ffmpegVideoStream = new VideoStream( videoStream.Index, videoStream.Codec, @@ -248,7 +258,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService videoVersion.MediaVersion.DisplayAspectRatio, new FrameRate(videoVersion.MediaVersion.RFrameRate), videoPath != audioPath, // still image when paths are different - scanKind); + scanKind) + { + HasMultipleProfiles = hasMultipleProfiles + }; var videoInputFile = new VideoInputFile( videoPath, @@ -405,8 +418,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService graphicsElementContexts.AddRange(watermarks.Map(wm => new WatermarkElementContext(wm))); } - HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings); - string videoFormat = GetVideoFormat(playbackSettings); Option maybeVideoProfile = GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile); Option maybeVideoPreset = GetVideoPreset( @@ -652,6 +663,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService return result; } + private async Task ProbeHasMultipleProfiles( + string ffmpegPath, + MediaItem mediaItem, + CancellationToken cancellationToken) + { + _logger.LogDebug("Will probe for h264 profile count"); + + Option profileCount = + await _localStatisticsProvider.GetProfileCount(ffmpegPath, mediaItem, cancellationToken); + + return await profileCount.IfNoneAsync(1) > 1; + } + public async Task ForError( string ffmpegPath, Channel channel, diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs index 933b64b33..0c882064a 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs @@ -14,4 +14,9 @@ public interface ILocalStatisticsProvider string ffmpegPath, MediaItem mediaItem, CancellationToken cancellationToken); + + Task> GetProfileCount( + string ffmpegPath, + MediaItem mediaItem, + CancellationToken cancellationToken); } diff --git a/ErsatzTV.FFmpeg/MediaStream.cs b/ErsatzTV.FFmpeg/MediaStream.cs index 224c8b7ac..54f11964d 100644 --- a/ErsatzTV.FFmpeg/MediaStream.cs +++ b/ErsatzTV.FFmpeg/MediaStream.cs @@ -32,6 +32,8 @@ public record VideoStream( { public ColorParams ColorParams { get; private set; } = ColorParams; + public bool HasMultipleProfiles { get; set; } + public int BitDepth => PixelFormat.Map(pf => pf.BitDepth).IfNone(8); public string SampleAspectRatio diff --git a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs index 1275a9742..2e3970f81 100644 --- a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs @@ -91,6 +91,14 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder decodeCapability = FFmpegCapability.Software; } + // QSV cannot always decode properly when *not* seeking, so check if software is needed + if (decodeCapability == FFmpegCapability.Hardware && ffmpegState.Start.Filter(s => s > TimeSpan.Zero).IsNone && + videoStream.HasMultipleProfiles) + { + _logger.LogDebug("Forcing software decode when input file uses multiple h264 profiles"); + decodeCapability = FFmpegCapability.Software; + } + // give a bogus value so no cuda devices are visible to ffmpeg pipelineSteps.Add(new CudaVisibleDevicesVariable("999")); diff --git a/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs index cff4a5f58..3ebbc2914 100644 --- a/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs @@ -181,6 +181,24 @@ public partial class LocalStatisticsProvider : ILocalStatisticsProvider return Option.None; } + public async Task> GetProfileCount( + string ffmpegPath, + MediaItem mediaItem, + CancellationToken cancellationToken) + { + try + { + string filePath = await PathForMediaItem(mediaItem); + return await GetProfileCount(ffmpegPath, filePath, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check profile count for media item {Id}", mediaItem.Id); + } + + return Option.None; + } + private async Task> RefreshStatistics( string ffmpegPath, string ffprobePath, @@ -336,6 +354,50 @@ public partial class LocalStatisticsProvider : ILocalStatisticsProvider return stats; } + private async Task> GetProfileCount( + string ffmpegPath, + string filePath, + CancellationToken cancellationToken) + { + string[] arguments = + [ + "-hide_banner", + "-i", filePath, + "-c", "copy", + "-bsf:v", "trace_headers", + "-f", "null", "-" + ]; + + var uniqueProfiles = new System.Collections.Generic.HashSet(); + + CommandResult traceHeaders = await Cli.Wrap(ffmpegPath) + .WithArguments(arguments) + .WithValidation(CommandResultValidation.None) + .WithStandardErrorPipe( + PipeTarget.ToDelegate(line => + { + int idx = line.IndexOf("profile_idc", StringComparison.Ordinal); + if (idx >= 0) + { + uniqueProfiles.Add(line[idx..]); + } + })) + .ExecuteAsync(cancellationToken); + + if (traceHeaders.ExitCode != 0) + { + _logger.LogInformation( + "FFmpeg trace headers with arguments {Arguments} exited with code {ExitCode}", + arguments, + traceHeaders.ExitCode); + + return Option.None; + } + + return uniqueProfiles.Count; + } + + private async Task AnalyzeDuration(string ffmpegPath, string path, MediaVersion version) { try