From 601409b09d96d63ac1586f9ccc3d3944665cf012 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:25:12 -0600 Subject: [PATCH] implement optional color normalization --- .../FFmpeg/FFmpegLibraryProcessService.cs | 2 ++ ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs | 1 + .../FFmpeg/FFmpegPlaybackSettingsCalculator.cs | 5 +++++ ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs | 8 ++++++-- ErsatzTV.FFmpeg/FrameState.cs | 1 + ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs | 6 +++--- ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs | 8 ++++---- ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs | 8 ++++---- ErsatzTV.FFmpeg/Pipeline/RkmppPipelineBuilder.cs | 6 +++--- .../Pipeline/SoftwarePipelineBuilder.cs | 8 ++++---- ErsatzTV.FFmpeg/Pipeline/V4l2m2mPipelineBuilder.cs | 6 +++--- ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs | 14 +++++++------- .../Pipeline/VideoToolboxPipelineBuilder.cs | 6 +++--- 13 files changed, 46 insertions(+), 33 deletions(-) diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index add48f315..5bd884af8 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -517,6 +517,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService playbackSettings.VideoBitrate, playbackSettings.VideoBufferSize, playbackSettings.VideoTrackTimeScale, + playbackSettings.NormalizeColors, playbackSettings.Deinterlace); // only use graphics engine when we have elements, and are normalizing video @@ -722,6 +723,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService playbackSettings.VideoBitrate, playbackSettings.VideoBufferSize, playbackSettings.VideoTrackTimeScale, + playbackSettings.NormalizeColors, playbackSettings.Deinterlace); OutputFormatKind outputFormat = OutputFormatKind.MpegTs; diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs index b3eb07441..e0c7e0278 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs @@ -31,4 +31,5 @@ public class FFmpegPlaybackSettings public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; } public Option TargetLoudness { get; set; } public Option FrameRate { get; set; } + public bool NormalizeColors { get; set; } } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index 6befc3719..812da70de 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -118,6 +118,11 @@ public static class FFmpegPlaybackSettingsCalculator result.FrameRate = targetFramerate; } + if (ffmpegProfile.NormalizeColors) + { + result.NormalizeColors = true; + } + result.VideoTrackTimeScale = 90000; foreach (MediaStream stream in videoStream.Where(s => !s.AttachedPic)) diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index f39d6762a..34d0f4e1f 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -73,6 +73,7 @@ public class PipelineBuilderBaseTests 2000, 4000, 90_000, + false, false); var ffmpegState = new FFmpegState( @@ -175,6 +176,7 @@ public class PipelineBuilderBaseTests 2000, 4000, 90_000, + false, false); var ffmpegState = new FFmpegState( @@ -335,6 +337,7 @@ public class PipelineBuilderBaseTests 2000, 4000, 90_000, + false, false); var ffmpegState = new FFmpegState( @@ -387,7 +390,7 @@ public class PipelineBuilderBaseTests // 0.4.0 reference: "-nostdin -threads 1 -hide_banner -loglevel error -nostats -fflags +genpts+discardcorrupt+igndts -re -ss 00:14:33.6195516 -i /tmp/whatever.mkv -map 0:0 -map 0:a -c:v copy -flags cgop -sc_threshold 0 -c:a copy -movflags +faststart -metadata service_provider="ErsatzTV" -metadata service_name="ErsatzTV" -t 00:06:39.6934484 -f mpegts -mpegts_flags +initial_discontinuity pipe:1" command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1"); + "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:1 -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1"); } [Test] @@ -430,6 +433,7 @@ public class PipelineBuilderBaseTests 2000, 4000, 90_000, + false, false); var ffmpegState = new FFmpegState( @@ -480,7 +484,7 @@ public class PipelineBuilderBaseTests string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result); command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -flags cgop -sc_threshold 0 -c:v copy -c:a copy -f mp4 pipe:1"); + "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -readrate 1.0 -i /tmp/whatever.mkv -map 0:0 -map 0:a -muxdelay 0 -muxpreload 0 -movflags +faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov -c:v copy -c:a copy -f mp4 pipe:1"); } [Test] diff --git a/ErsatzTV.FFmpeg/FrameState.cs b/ErsatzTV.FFmpeg/FrameState.cs index 2869b40ab..d971b9fc6 100644 --- a/ErsatzTV.FFmpeg/FrameState.cs +++ b/ErsatzTV.FFmpeg/FrameState.cs @@ -19,6 +19,7 @@ public record FrameState( Option VideoBitrate, Option VideoBufferSize, Option VideoTrackTimeScale, + bool ColorsAreBt709, bool Deinterlaced, FrameDataLocation FrameDataLocation = FrameDataLocation.Unknown) { diff --git a/ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs index 8daad41dd..70e94ba4f 100644 --- a/ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs @@ -94,15 +94,15 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder protected override List SetPixelFormat( VideoStream videoStream, - Option desiredPixelFormat, + FrameState desiredState, FrameState currentState, ICollection pipelineSteps) { var result = new List(); - foreach (IPixelFormat pixelFormat in desiredPixelFormat) + foreach (IPixelFormat pixelFormat in desiredState.PixelFormat) { - if (!videoStream.ColorParams.IsBt709) + if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709) { // _logger.LogDebug("Adding colorspace filter"); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat); diff --git a/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs index f027df9d0..62dfbf7bc 100644 --- a/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs @@ -347,7 +347,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder List pixelFormatFilterSteps = SetPixelFormat( videoStream, - desiredState.PixelFormat, + desiredState, ffmpegState, currentState, context, @@ -392,7 +392,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder private List SetPixelFormat( VideoStream videoStream, - Option desiredPixelFormat, + FrameState desiredState, FFmpegState ffmpegState, FrameState currentState, PipelineContext context, @@ -400,7 +400,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder { var result = new List(); - foreach (IPixelFormat pixelFormat in desiredPixelFormat) + foreach (IPixelFormat pixelFormat in desiredState.PixelFormat) { IPixelFormat format = pixelFormat; @@ -419,7 +419,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder videoStream.ResetColorParams(ColorParams.Unknown); } - if (!videoStream.ColorParams.IsBt709) + if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709) { // _logger.LogDebug("Adding colorspace filter"); var colorspace = new ColorspaceFilter(currentState, videoStream, format); diff --git a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs index 88ca70040..1275a9742 100644 --- a/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs @@ -259,7 +259,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder List pixelFormatFilterSteps = SetPixelFormat( videoInputFile, videoStream, - desiredState.PixelFormat, + desiredState, ffmpegState, currentState, context, @@ -279,7 +279,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder private List SetPixelFormat( VideoInputFile videoInputFile, VideoStream videoStream, - Option desiredPixelFormat, + FrameState desiredState, FFmpegState ffmpegState, FrameState currentState, PipelineContext context, @@ -287,7 +287,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder { var result = new List(); - foreach (IPixelFormat pixelFormat in desiredPixelFormat) + foreach (IPixelFormat pixelFormat in desiredState.PixelFormat) { IPixelFormat format = pixelFormat; @@ -352,7 +352,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder } } - if (!videoStream.ColorParams.IsBt709 || usesVppQsv) + if (desiredState.ColorsAreBt709 && (!videoStream.ColorParams.IsBt709 || usesVppQsv)) { // _logger.LogDebug("Adding colorspace filter"); diff --git a/ErsatzTV.FFmpeg/Pipeline/RkmppPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/RkmppPipelineBuilder.cs index 0113706e1..6c9a4301e 100644 --- a/ErsatzTV.FFmpeg/Pipeline/RkmppPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/RkmppPipelineBuilder.cs @@ -126,15 +126,15 @@ public class RkmppPipelineBuilder : SoftwarePipelineBuilder protected override List SetPixelFormat( VideoStream videoStream, - Option desiredPixelFormat, + FrameState desiredState, FrameState currentState, ICollection pipelineSteps) { var result = new List(); - foreach (IPixelFormat pixelFormat in desiredPixelFormat) + foreach (IPixelFormat pixelFormat in desiredState.PixelFormat) { - if (!videoStream.ColorParams.IsBt709) + if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709) { // _logger.LogDebug("Adding colorspace filter"); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat); diff --git a/ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs index f40fb3f60..8410e47ee 100644 --- a/ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs @@ -152,7 +152,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase List pixelFormatFilterSteps = SetPixelFormat( videoStream, - desiredState.PixelFormat, + desiredState, currentState, pipelineSteps); @@ -169,15 +169,15 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase protected virtual List SetPixelFormat( VideoStream videoStream, - Option desiredPixelFormat, + FrameState desiredState, FrameState currentState, ICollection pipelineSteps) { var result = new List(); - foreach (IPixelFormat pixelFormat in desiredPixelFormat) + foreach (IPixelFormat pixelFormat in desiredState.PixelFormat) { - if (!videoStream.ColorParams.IsBt709) + if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709) { // _logger.LogDebug("Adding colorspace filter"); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat); diff --git a/ErsatzTV.FFmpeg/Pipeline/V4l2m2mPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/V4l2m2mPipelineBuilder.cs index b71ee0805..86a162aee 100644 --- a/ErsatzTV.FFmpeg/Pipeline/V4l2m2mPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/V4l2m2mPipelineBuilder.cs @@ -124,15 +124,15 @@ public class V4l2m2mPipelineBuilder : SoftwarePipelineBuilder protected override List SetPixelFormat( VideoStream videoStream, - Option desiredPixelFormat, + FrameState desiredState, FrameState currentState, ICollection pipelineSteps) { var result = new List(); - foreach (IPixelFormat pixelFormat in desiredPixelFormat) + foreach (IPixelFormat pixelFormat in desiredState.PixelFormat) { - if (!videoStream.ColorParams.IsBt709) + if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709) { // _logger.LogDebug("Adding colorspace filter"); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat); diff --git a/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs index a137b482b..dd3d270f8 100644 --- a/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs @@ -286,7 +286,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder List pixelFormatFilterSteps = SetPixelFormat( videoStream, - desiredState.PixelFormat, + desiredState, ffmpegState, currentState, pipelineSteps); @@ -313,14 +313,14 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder private List SetPixelFormat( VideoStream videoStream, - Option desiredPixelFormat, + FrameState desiredState, FFmpegState ffmpegState, FrameState currentState, ICollection pipelineSteps) { var result = new List(); - foreach (IPixelFormat pixelFormat in desiredPixelFormat) + foreach (IPixelFormat pixelFormat in desiredState.PixelFormat) { IPixelFormat format = pixelFormat; @@ -332,7 +332,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder } } - if (!videoStream.ColorParams.IsBt709) + if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709) { // _logger.LogDebug("Adding colorspace filter"); var colorspace = new ColorspaceFilter( @@ -352,12 +352,12 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder _logger.LogDebug( "FrameDataLocation == FrameDataLocation.Hardware, {CurrentPixelFormat} bit => {DesiredPixelFormat}", currentState.PixelFormat, - desiredPixelFormat); + desiredState.PixelFormat); // don't try to download from 8-bit to 10-bit, or 10-bit to 8-bit HardwareDownloadFilter hardwareDownload = - currentState.BitDepth == 8 && desiredPixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 10 || - currentState.BitDepth == 10 && desiredPixelFormat.Map(pf => pf.BitDepth).IfNone(10) == 8 + currentState.BitDepth == 8 && desiredState.PixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 10 || + currentState.BitDepth == 10 && desiredState.PixelFormat.Map(pf => pf.BitDepth).IfNone(10) == 8 ? new HardwareDownloadFilter(currentState) : new HardwareDownloadFilter(currentState with { PixelFormat = Some(format) }); diff --git a/ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs b/ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs index dd03af6b9..8bccad7c9 100644 --- a/ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs @@ -123,15 +123,15 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder protected override List SetPixelFormat( VideoStream videoStream, - Option desiredPixelFormat, + FrameState desiredState, FrameState currentState, ICollection pipelineSteps) { var result = new List(); - foreach (IPixelFormat pixelFormat in desiredPixelFormat) + foreach (IPixelFormat pixelFormat in desiredState.PixelFormat) { - if (!videoStream.ColorParams.IsBt709) + if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709) { // _logger.LogDebug("Adding colorspace filter"); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);