Browse Source

add normalization options (#2802)

* add new fields to database

* update editor

* audio and video normalization settings appear to work

* implement optional color normalization

* fix transcoding tests

* update changelog
pull/2803/head
Jason Dove 6 months ago committed by GitHub
parent
commit
e10b28bc0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs
  3. 99
      ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs
  4. 3
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs
  5. 13
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs
  6. 3
      ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs
  7. 3
      ErsatzTV.Application/FFmpegProfiles/Mapper.cs
  8. 8
      ErsatzTV.Core/Domain/FFmpegProfile.cs
  9. 6
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  10. 1
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs
  11. 5
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  12. 8
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  13. 1
      ErsatzTV.FFmpeg/FrameState.cs
  14. 20
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs
  15. 6
      ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs
  16. 8
      ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs
  17. 38
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  18. 8
      ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs
  19. 6
      ErsatzTV.FFmpeg/Pipeline/RkmppPipelineBuilder.cs
  20. 8
      ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs
  21. 6
      ErsatzTV.FFmpeg/Pipeline/V4l2m2mPipelineBuilder.cs
  22. 14
      ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs
  23. 6
      ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs
  24. 7039
      ErsatzTV.Infrastructure.MySql/Migrations/20260127040133_Add_FFmpegProfileNormalizationOptions.Designer.cs
  25. 51
      ErsatzTV.Infrastructure.MySql/Migrations/20260127040133_Add_FFmpegProfileNormalizationOptions.cs
  26. 15
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  27. 6866
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260127040052_Add_FFmpegProfileNormalizationOptions.Designer.cs
  28. 51
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260127040052_Add_FFmpegProfileNormalizationOptions.cs
  29. 15
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  30. 9
      ErsatzTV.Infrastructure/Data/Configurations/FFmpegProfileConfiguration.cs
  31. 31
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  32. 30
      ErsatzTV/Pages/Channels.razor
  33. 454
      ErsatzTV/Pages/FFmpegEditor.razor
  34. 45
      ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs

5
CHANGELOG.md

@ -24,6 +24,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add `Marathon Group By` option `Director` - Add `Marathon Group By` option `Director`
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos - This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
- This is supported in classic schedules and sequential schedules - This is supported in classic schedules and sequential schedules
- Add FFmpeg Profile options:
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
### Changed ### Changed
- Disable automatic artwork database cleanup - Disable automatic artwork database cleanup

3
ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs

@ -7,6 +7,8 @@ namespace ErsatzTV.Application.FFmpegProfiles;
public record CreateFFmpegProfile( public record CreateFFmpegProfile(
string Name, string Name,
int ThreadCount, int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration, HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay, string VaapiDisplay,
VaapiDriver VaapiDriver, VaapiDriver VaapiDriver,
@ -31,4 +33,5 @@ public record CreateFFmpegProfile(
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>; bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;

99
ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs

@ -1,7 +1,6 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg.Pipeline;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -45,51 +44,63 @@ public class CreateFFmpegProfileHandler :
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
(ValidateName(request), ValidateThreadCount(request), (ValidateName(request), ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken)) await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((name, threadCount, resolutionId) => new FFmpegProfile .Apply((name, threadCount, resolutionId) =>
{ {
Name = name, var hwAccel = request.NormalizeVideo
ThreadCount = threadCount, ? request.HardwareAcceleration
HardwareAcceleration = request.HardwareAcceleration, : HardwareAccelerationKind.None;
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice, return new FFmpegProfile
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
// only allow customization with VAAPI accel
PadMode = request.HardwareAcceleration switch
{ {
HardwareAccelerationKind.None => FilterMode.Software, Name = name,
HardwareAccelerationKind.Vaapi => request.PadMode, ThreadCount = threadCount,
_ => FilterMode.HardwareIfPossible
}, NormalizeAudio = request.NormalizeAudio,
NormalizeVideo = request.NormalizeVideo,
VideoFormat = request.VideoFormat,
VideoProfile = request.VideoProfile, HardwareAcceleration = hwAccel,
VideoPreset = request.VideoPreset, VaapiDriver = request.VaapiDriver,
AllowBFrames = request.AllowBFrames, VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
// mpeg2video only supports 8-bit content ResolutionId = resolutionId,
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video ScalingBehavior = request.ScalingBehavior,
? FFmpegProfileBitDepth.EightBit
: request.BitDepth, // only allow customization with VAAPI accel
PadMode = hwAccel switch
VideoBitrate = request.VideoBitrate, {
VideoBufferSize = request.VideoBufferSize, HardwareAccelerationKind.None => FilterMode.Software,
TonemapAlgorithm = request.TonemapAlgorithm, HardwareAccelerationKind.Vaapi => request.PadMode,
AudioFormat = request.AudioFormat, _ => FilterMode.HardwareIfPossible
AudioBitrate = request.AudioBitrate, },
AudioBufferSize = request.AudioBufferSize,
VideoFormat = request.NormalizeVideo ? request.VideoFormat : FFmpegProfileVideoFormat.Copy,
NormalizeLoudnessMode = request.NormalizeLoudnessMode, VideoProfile = request.VideoProfile,
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm VideoPreset = request.VideoPreset,
? request.TargetLoudness AllowBFrames = request.AllowBFrames,
: null,
// mpeg2video only supports 8-bit content
AudioChannels = request.AudioChannels, BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
AudioSampleRate = request.AudioSampleRate, ? FFmpegProfileBitDepth.EightBit
NormalizeFramerate = request.NormalizeFramerate, : request.BitDepth,
DeinterlaceVideo = request.DeinterlaceVideo
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
TonemapAlgorithm = request.TonemapAlgorithm,
AudioFormat = request.NormalizeAudio ? request.AudioFormat : FFmpegProfileAudioFormat.Copy,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? request.TargetLoudness
: null,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
NormalizeColors = request.NormalizeColors,
DeinterlaceVideo = request.DeinterlaceVideo
};
}); });
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) => private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>

3
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs

@ -8,6 +8,8 @@ public record UpdateFFmpegProfile(
int FFmpegProfileId, int FFmpegProfileId,
string Name, string Name,
int ThreadCount, int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration, HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay, string VaapiDisplay,
VaapiDriver VaapiDriver, VaapiDriver VaapiDriver,
@ -32,4 +34,5 @@ public record UpdateFFmpegProfile(
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>; bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;

13
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs

@ -27,9 +27,15 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
UpdateFFmpegProfile update, UpdateFFmpegProfile update,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var hwAccel = update.NormalizeVideo
? update.HardwareAcceleration
: HardwareAccelerationKind.None;
p.Name = update.Name; p.Name = update.Name;
p.ThreadCount = update.ThreadCount; p.ThreadCount = update.ThreadCount;
p.HardwareAcceleration = update.HardwareAcceleration; p.NormalizeAudio = update.NormalizeAudio;
p.NormalizeVideo = update.NormalizeVideo;
p.HardwareAcceleration = hwAccel;
p.VaapiDisplay = update.VaapiDisplay; p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver; p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice; p.VaapiDevice = update.VaapiDevice;
@ -37,7 +43,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.ResolutionId = update.ResolutionId; p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior; p.ScalingBehavior = update.ScalingBehavior;
p.PadMode = update.PadMode; p.PadMode = update.PadMode;
p.VideoFormat = update.VideoFormat; p.VideoFormat = update.NormalizeVideo ? update.VideoFormat : FFmpegProfileVideoFormat.Copy;
p.VideoProfile = update.VideoProfile; p.VideoProfile = update.VideoProfile;
p.VideoPreset = update.VideoPreset; p.VideoPreset = update.VideoPreset;
p.AllowBFrames = update.AllowBFrames; p.AllowBFrames = update.AllowBFrames;
@ -67,7 +73,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.VideoBitrate = update.VideoBitrate; p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize; p.VideoBufferSize = update.VideoBufferSize;
p.TonemapAlgorithm = update.TonemapAlgorithm; p.TonemapAlgorithm = update.TonemapAlgorithm;
p.AudioFormat = update.AudioFormat; p.AudioFormat = update.NormalizeAudio ? update.AudioFormat : FFmpegProfileAudioFormat.Copy;
p.AudioBitrate = update.AudioBitrate; p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize; p.AudioBufferSize = update.AudioBufferSize;
@ -79,6 +85,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.AudioChannels = update.AudioChannels; p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate; p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate; p.NormalizeFramerate = update.NormalizeFramerate;
p.NormalizeColors = update.NormalizeColors;
p.DeinterlaceVideo = update.DeinterlaceVideo; p.DeinterlaceVideo = update.DeinterlaceVideo;
// don't save invalid preset // don't save invalid preset

3
ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs

@ -8,6 +8,8 @@ public record FFmpegProfileViewModel(
int Id, int Id,
string Name, string Name,
int ThreadCount, int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration, HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay, string VaapiDisplay,
VaapiDriver VaapiDriver, VaapiDriver VaapiDriver,
@ -32,4 +34,5 @@ public record FFmpegProfileViewModel(
int AudioChannels, int AudioChannels,
int AudioSampleRate, int AudioSampleRate,
bool NormalizeFramerate, bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo); bool DeinterlaceVideo);

3
ErsatzTV.Application/FFmpegProfiles/Mapper.cs

@ -10,6 +10,8 @@ internal static class Mapper
profile.Id, profile.Id,
profile.Name, profile.Name,
profile.ThreadCount, profile.ThreadCount,
profile.NormalizeAudio,
profile.NormalizeVideo,
profile.HardwareAcceleration, profile.HardwareAcceleration,
profile.VaapiDisplay ?? "drm", profile.VaapiDisplay ?? "drm",
profile.VaapiDriver, profile.VaapiDriver,
@ -34,6 +36,7 @@ internal static class Mapper
profile.AudioChannels, profile.AudioChannels,
profile.AudioSampleRate, profile.AudioSampleRate,
profile.NormalizeFramerate, profile.NormalizeFramerate,
profile.NormalizeColors,
profile.DeinterlaceVideo == true); profile.DeinterlaceVideo == true);
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) => internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>

8
ErsatzTV.Core/Domain/FFmpegProfile.cs

@ -7,6 +7,8 @@ public record FFmpegProfile
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public int ThreadCount { get; set; } public int ThreadCount { get; set; }
public bool NormalizeAudio { get; set; }
public bool NormalizeVideo { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; } public HardwareAccelerationKind HardwareAcceleration { get; set; }
public string VaapiDisplay { get; set; } public string VaapiDisplay { get; set; }
public VaapiDriver VaapiDriver { get; set; } public VaapiDriver VaapiDriver { get; set; }
@ -32,6 +34,7 @@ public record FFmpegProfile
public int AudioChannels { get; set; } public int AudioChannels { get; set; }
public int AudioSampleRate { get; set; } public int AudioSampleRate { get; set; }
public bool NormalizeFramerate { get; set; } public bool NormalizeFramerate { get; set; }
public bool NormalizeColors { get; set; }
public bool? DeinterlaceVideo { get; set; } public bool? DeinterlaceVideo { get; set; }
public static FFmpegProfile New(string name, Resolution resolution) => public static FFmpegProfile New(string name, Resolution resolution) =>
@ -59,6 +62,9 @@ public record FFmpegProfile
DeinterlaceVideo = true, DeinterlaceVideo = true,
NormalizeFramerate = false, NormalizeFramerate = false,
HardwareAcceleration = HardwareAccelerationKind.None, HardwareAcceleration = HardwareAccelerationKind.None,
QsvExtraHardwareFrames = 64 QsvExtraHardwareFrames = 64,
NormalizeAudio = true,
NormalizeVideo = true,
NormalizeColors = true
}; };
} }

6
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -517,10 +517,11 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.VideoBitrate, playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize, playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale, playbackSettings.VideoTrackTimeScale,
playbackSettings.NormalizeColors,
playbackSettings.Deinterlace); playbackSettings.Deinterlace);
// only use graphics engine when we have elements // only use graphics engine when we have elements, and are normalizing video
if (graphicsElementContexts.Count > 0 || graphicsElements.Count > 0) if (videoFormat != VideoFormat.Copy && (graphicsElementContexts.Count > 0 || graphicsElements.Count > 0))
{ {
FrameSize targetSize = await desiredState.CroppedSize.IfNoneAsync(desiredState.ScaledSize); FrameSize targetSize = await desiredState.CroppedSize.IfNoneAsync(desiredState.ScaledSize);
@ -722,6 +723,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.VideoBitrate, playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize, playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale, playbackSettings.VideoTrackTimeScale,
playbackSettings.NormalizeColors,
playbackSettings.Deinterlace); playbackSettings.Deinterlace);
OutputFormatKind outputFormat = OutputFormatKind.MpegTs; OutputFormatKind outputFormat = OutputFormatKind.MpegTs;

1
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettings.cs

@ -31,4 +31,5 @@ public class FFmpegPlaybackSettings
public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; } public NormalizeLoudnessMode NormalizeLoudnessMode { get; set; }
public Option<double> TargetLoudness { get; set; } public Option<double> TargetLoudness { get; set; }
public Option<FrameRate> FrameRate { get; set; } public Option<FrameRate> FrameRate { get; set; }
public bool NormalizeColors { get; set; }
} }

5
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -118,6 +118,11 @@ public static class FFmpegPlaybackSettingsCalculator
result.FrameRate = targetFramerate; result.FrameRate = targetFramerate;
} }
if (ffmpegProfile.NormalizeColors)
{
result.NormalizeColors = true;
}
result.VideoTrackTimeScale = 90000; result.VideoTrackTimeScale = 90000;
foreach (MediaStream stream in videoStream.Where(s => !s.AttachedPic)) foreach (MediaStream stream in videoStream.Where(s => !s.AttachedPic))

8
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -73,6 +73,7 @@ public class PipelineBuilderBaseTests
2000, 2000,
4000, 4000,
90_000, 90_000,
false,
false); false);
var ffmpegState = new FFmpegState( var ffmpegState = new FFmpegState(
@ -175,6 +176,7 @@ public class PipelineBuilderBaseTests
2000, 2000,
4000, 4000,
90_000, 90_000,
false,
false); false);
var ffmpegState = new FFmpegState( var ffmpegState = new FFmpegState(
@ -335,6 +337,7 @@ public class PipelineBuilderBaseTests
2000, 2000,
4000, 4000,
90_000, 90_000,
false,
false); false);
var ffmpegState = new FFmpegState( 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" // 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( 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] [Test]
@ -430,6 +433,7 @@ public class PipelineBuilderBaseTests
2000, 2000,
4000, 4000,
90_000, 90_000,
false,
false); false);
var ffmpegState = new FFmpegState( var ffmpegState = new FFmpegState(
@ -480,7 +484,7 @@ public class PipelineBuilderBaseTests
string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result); string command = PrintCommand(videoInputFile, audioInputFile, None, None, None, result);
command.ShouldBe( 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] [Test]

1
ErsatzTV.FFmpeg/FrameState.cs

@ -19,6 +19,7 @@ public record FrameState(
Option<int> VideoBitrate, Option<int> VideoBitrate,
Option<int> VideoBufferSize, Option<int> VideoBufferSize,
Option<int> VideoTrackTimeScale, Option<int> VideoTrackTimeScale,
bool ColorsAreBt709,
bool Deinterlaced, bool Deinterlaced,
FrameDataLocation FrameDataLocation = FrameDataLocation.Unknown) FrameDataLocation FrameDataLocation = FrameDataLocation.Unknown)
{ {

20
ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs

@ -1,4 +1,5 @@
using ErsatzTV.FFmpeg.Environment; using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.OutputFormat; namespace ErsatzTV.FFmpeg.OutputFormat;
@ -56,18 +57,27 @@ public class OutputFormatHls : IPipelineStep
? (int)Math.Round(frameRate.ParsedFrameRate) ? (int)Math.Round(frameRate.ParsedFrameRate)
: (int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds); : (int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds);
List<string> result = List<string> result = [];
if (_desiredState.VideoFormat != VideoFormat.Copy)
{
result.AddRange(
[
"-g", $"{gop}",
"-keyint_min", $"{(int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds)}",
"-force_key_frames", $"expr:gte(t,n_forced*{SegmentSeconds})"
]);
}
result.AddRange(
[ [
"-g", $"{gop}",
"-keyint_min", $"{(int)Math.Round(frameRate.ParsedFrameRate * SegmentSeconds)}",
"-force_key_frames", $"expr:gte(t,n_forced*{SegmentSeconds})",
"-f", "hls", "-f", "hls",
"-hls_time", $"{SegmentSeconds}", "-hls_time", $"{SegmentSeconds}",
"-hls_list_size", "0", "-hls_list_size", "0",
"-segment_list_flags", "+live", "-segment_list_flags", "+live",
"-hls_segment_filename", "-hls_segment_filename",
_segmentTemplate _segmentTemplate
]; ]);
var independentSegments = "+independent_segments"; var independentSegments = "+independent_segments";

6
ErsatzTV.FFmpeg/Pipeline/AmfPipelineBuilder.cs

@ -94,15 +94,15 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder
protected override List<IPipelineFilterStep> SetPixelFormat( protected override List<IPipelineFilterStep> SetPixelFormat(
VideoStream videoStream, VideoStream videoStream,
Option<IPixelFormat> desiredPixelFormat, FrameState desiredState,
FrameState currentState, FrameState currentState,
ICollection<IPipelineStep> pipelineSteps) ICollection<IPipelineStep> pipelineSteps)
{ {
var result = new List<IPipelineFilterStep>(); var result = new List<IPipelineFilterStep>();
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"); // _logger.LogDebug("Adding colorspace filter");
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);

8
ErsatzTV.FFmpeg/Pipeline/NvidiaPipelineBuilder.cs

@ -347,7 +347,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat( List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat(
videoStream, videoStream,
desiredState.PixelFormat, desiredState,
ffmpegState, ffmpegState,
currentState, currentState,
context, context,
@ -392,7 +392,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
private List<IPipelineFilterStep> SetPixelFormat( private List<IPipelineFilterStep> SetPixelFormat(
VideoStream videoStream, VideoStream videoStream,
Option<IPixelFormat> desiredPixelFormat, FrameState desiredState,
FFmpegState ffmpegState, FFmpegState ffmpegState,
FrameState currentState, FrameState currentState,
PipelineContext context, PipelineContext context,
@ -400,7 +400,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
{ {
var result = new List<IPipelineFilterStep>(); var result = new List<IPipelineFilterStep>();
foreach (IPixelFormat pixelFormat in desiredPixelFormat) foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
{ {
IPixelFormat format = pixelFormat; IPixelFormat format = pixelFormat;
@ -419,7 +419,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
videoStream.ResetColorParams(ColorParams.Unknown); videoStream.ResetColorParams(ColorParams.Unknown);
} }
if (!videoStream.ColorParams.IsBt709) if (desiredState.ColorsAreBt709 && !videoStream.ColorParams.IsBt709)
{ {
// _logger.LogDebug("Adding colorspace filter"); // _logger.LogDebug("Adding colorspace filter");
var colorspace = new ColorspaceFilter(currentState, videoStream, format); var colorspace = new ColorspaceFilter(currentState, videoStream, format);

38
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -214,10 +214,14 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
new LoglevelErrorOption(), new LoglevelErrorOption(),
new StandardFormatFlags(), new StandardFormatFlags(),
new NoDemuxDecodeDelayOutputOption(), new NoDemuxDecodeDelayOutputOption(),
outputOption, outputOption
new ClosedGopOutputOption()
}; };
if (desiredState.VideoFormat != VideoFormat.Copy)
{
pipelineSteps.Add(new ClosedGopOutputOption());
}
if (desiredState.VideoFormat != VideoFormat.Copy && !desiredState.AllowBFrames) if (desiredState.VideoFormat != VideoFormat.Copy && !desiredState.AllowBFrames)
{ {
pipelineSteps.Add(new NoBFramesOutputOption()); pipelineSteps.Add(new NoBFramesOutputOption());
@ -512,6 +516,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
private void SetAudioLoudness(AudioInputFile audioInputFile) private void SetAudioLoudness(AudioInputFile audioInputFile)
{ {
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
{
return;
}
if (audioInputFile.DesiredState.NormalizeLoudnessFilter is not AudioFilter.None) if (audioInputFile.DesiredState.NormalizeLoudnessFilter is not AudioFilter.None)
{ {
var filter = new NormalizeLoudnessFilter( var filter = new NormalizeLoudnessFilter(
@ -525,6 +534,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
private static void SetAudioSampleRate(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps) private static void SetAudioSampleRate(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
{ {
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
{
return;
}
foreach (int desiredSampleRate in audioInputFile.DesiredState.AudioSampleRate) foreach (int desiredSampleRate in audioInputFile.DesiredState.AudioSampleRate)
{ {
pipelineSteps.Add(new AudioSampleRateOutputOption(desiredSampleRate)); pipelineSteps.Add(new AudioSampleRateOutputOption(desiredSampleRate));
@ -533,6 +547,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
private static void SetAudioBufferSize(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps) private static void SetAudioBufferSize(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
{ {
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
{
return;
}
foreach (int desiredBufferSize in audioInputFile.DesiredState.AudioBufferSize) foreach (int desiredBufferSize in audioInputFile.DesiredState.AudioBufferSize)
{ {
pipelineSteps.Add(new AudioBufferSizeOutputOption(desiredBufferSize)); pipelineSteps.Add(new AudioBufferSizeOutputOption(desiredBufferSize));
@ -541,6 +560,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
private static void SetAudioBitrate(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps) private static void SetAudioBitrate(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
{ {
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
{
return;
}
foreach (int desiredBitrate in audioInputFile.DesiredState.AudioBitrate) foreach (int desiredBitrate in audioInputFile.DesiredState.AudioBitrate)
{ {
pipelineSteps.Add(new AudioBitrateOutputOption(desiredBitrate)); pipelineSteps.Add(new AudioBitrateOutputOption(desiredBitrate));
@ -549,6 +573,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
private static void SetAudioChannels(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps) private static void SetAudioChannels(AudioInputFile audioInputFile, List<IPipelineStep> pipelineSteps)
{ {
if (audioInputFile.DesiredState.AudioFormat == AudioFormat.Copy)
{
return;
}
foreach (AudioStream audioStream in audioInputFile.AudioStreams.HeadOrNone()) foreach (AudioStream audioStream in audioInputFile.AudioStreams.HeadOrNone())
{ {
foreach (int desiredAudioChannels in audioInputFile.DesiredState.AudioChannels) foreach (int desiredAudioChannels in audioInputFile.DesiredState.AudioChannels)
@ -878,6 +907,11 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
FrameState desiredState, FrameState desiredState,
List<IPipelineStep> pipelineSteps) List<IPipelineStep> pipelineSteps)
{ {
if (desiredState.VideoFormat == VideoFormat.Copy)
{
return;
}
// -sc_threshold 0 is unsupported with mpeg2video // -sc_threshold 0 is unsupported with mpeg2video
if (videoStream.Codec == VideoFormat.Mpeg2Video || desiredState.VideoFormat == VideoFormat.Mpeg2Video || if (videoStream.Codec == VideoFormat.Mpeg2Video || desiredState.VideoFormat == VideoFormat.Mpeg2Video ||
ffmpegState.DecoderHardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox) ffmpegState.DecoderHardwareAccelerationMode == HardwareAccelerationMode.VideoToolbox)

8
ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs

@ -259,7 +259,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat( List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat(
videoInputFile, videoInputFile,
videoStream, videoStream,
desiredState.PixelFormat, desiredState,
ffmpegState, ffmpegState,
currentState, currentState,
context, context,
@ -279,7 +279,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
private List<IPipelineFilterStep> SetPixelFormat( private List<IPipelineFilterStep> SetPixelFormat(
VideoInputFile videoInputFile, VideoInputFile videoInputFile,
VideoStream videoStream, VideoStream videoStream,
Option<IPixelFormat> desiredPixelFormat, FrameState desiredState,
FFmpegState ffmpegState, FFmpegState ffmpegState,
FrameState currentState, FrameState currentState,
PipelineContext context, PipelineContext context,
@ -287,7 +287,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
{ {
var result = new List<IPipelineFilterStep>(); var result = new List<IPipelineFilterStep>();
foreach (IPixelFormat pixelFormat in desiredPixelFormat) foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
{ {
IPixelFormat format = 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"); // _logger.LogDebug("Adding colorspace filter");

6
ErsatzTV.FFmpeg/Pipeline/RkmppPipelineBuilder.cs

@ -126,15 +126,15 @@ public class RkmppPipelineBuilder : SoftwarePipelineBuilder
protected override List<IPipelineFilterStep> SetPixelFormat( protected override List<IPipelineFilterStep> SetPixelFormat(
VideoStream videoStream, VideoStream videoStream,
Option<IPixelFormat> desiredPixelFormat, FrameState desiredState,
FrameState currentState, FrameState currentState,
ICollection<IPipelineStep> pipelineSteps) ICollection<IPipelineStep> pipelineSteps)
{ {
var result = new List<IPipelineFilterStep>(); var result = new List<IPipelineFilterStep>();
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"); // _logger.LogDebug("Adding colorspace filter");
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);

8
ErsatzTV.FFmpeg/Pipeline/SoftwarePipelineBuilder.cs

@ -152,7 +152,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat( List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat(
videoStream, videoStream,
desiredState.PixelFormat, desiredState,
currentState, currentState,
pipelineSteps); pipelineSteps);
@ -169,15 +169,15 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
protected virtual List<IPipelineFilterStep> SetPixelFormat( protected virtual List<IPipelineFilterStep> SetPixelFormat(
VideoStream videoStream, VideoStream videoStream,
Option<IPixelFormat> desiredPixelFormat, FrameState desiredState,
FrameState currentState, FrameState currentState,
ICollection<IPipelineStep> pipelineSteps) ICollection<IPipelineStep> pipelineSteps)
{ {
var result = new List<IPipelineFilterStep>(); var result = new List<IPipelineFilterStep>();
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"); // _logger.LogDebug("Adding colorspace filter");
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);

6
ErsatzTV.FFmpeg/Pipeline/V4l2m2mPipelineBuilder.cs

@ -124,15 +124,15 @@ public class V4l2m2mPipelineBuilder : SoftwarePipelineBuilder
protected override List<IPipelineFilterStep> SetPixelFormat( protected override List<IPipelineFilterStep> SetPixelFormat(
VideoStream videoStream, VideoStream videoStream,
Option<IPixelFormat> desiredPixelFormat, FrameState desiredState,
FrameState currentState, FrameState currentState,
ICollection<IPipelineStep> pipelineSteps) ICollection<IPipelineStep> pipelineSteps)
{ {
var result = new List<IPipelineFilterStep>(); var result = new List<IPipelineFilterStep>();
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"); // _logger.LogDebug("Adding colorspace filter");
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);

14
ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs

@ -286,7 +286,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat( List<IPipelineFilterStep> pixelFormatFilterSteps = SetPixelFormat(
videoStream, videoStream,
desiredState.PixelFormat, desiredState,
ffmpegState, ffmpegState,
currentState, currentState,
pipelineSteps); pipelineSteps);
@ -313,14 +313,14 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
private List<IPipelineFilterStep> SetPixelFormat( private List<IPipelineFilterStep> SetPixelFormat(
VideoStream videoStream, VideoStream videoStream,
Option<IPixelFormat> desiredPixelFormat, FrameState desiredState,
FFmpegState ffmpegState, FFmpegState ffmpegState,
FrameState currentState, FrameState currentState,
ICollection<IPipelineStep> pipelineSteps) ICollection<IPipelineStep> pipelineSteps)
{ {
var result = new List<IPipelineFilterStep>(); var result = new List<IPipelineFilterStep>();
foreach (IPixelFormat pixelFormat in desiredPixelFormat) foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
{ {
IPixelFormat format = 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"); // _logger.LogDebug("Adding colorspace filter");
var colorspace = new ColorspaceFilter( var colorspace = new ColorspaceFilter(
@ -352,12 +352,12 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
_logger.LogDebug( _logger.LogDebug(
"FrameDataLocation == FrameDataLocation.Hardware, {CurrentPixelFormat} bit => {DesiredPixelFormat}", "FrameDataLocation == FrameDataLocation.Hardware, {CurrentPixelFormat} bit => {DesiredPixelFormat}",
currentState.PixelFormat, currentState.PixelFormat,
desiredPixelFormat); desiredState.PixelFormat);
// don't try to download from 8-bit to 10-bit, or 10-bit to 8-bit // don't try to download from 8-bit to 10-bit, or 10-bit to 8-bit
HardwareDownloadFilter hardwareDownload = HardwareDownloadFilter hardwareDownload =
currentState.BitDepth == 8 && desiredPixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 10 || currentState.BitDepth == 8 && desiredState.PixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 10 ||
currentState.BitDepth == 10 && desiredPixelFormat.Map(pf => pf.BitDepth).IfNone(10) == 8 currentState.BitDepth == 10 && desiredState.PixelFormat.Map(pf => pf.BitDepth).IfNone(10) == 8
? new HardwareDownloadFilter(currentState) ? new HardwareDownloadFilter(currentState)
: new HardwareDownloadFilter(currentState with { PixelFormat = Some(format) }); : new HardwareDownloadFilter(currentState with { PixelFormat = Some(format) });

6
ErsatzTV.FFmpeg/Pipeline/VideoToolboxPipelineBuilder.cs

@ -123,15 +123,15 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder
protected override List<IPipelineFilterStep> SetPixelFormat( protected override List<IPipelineFilterStep> SetPixelFormat(
VideoStream videoStream, VideoStream videoStream,
Option<IPixelFormat> desiredPixelFormat, FrameState desiredState,
FrameState currentState, FrameState currentState,
ICollection<IPipelineStep> pipelineSteps) ICollection<IPipelineStep> pipelineSteps)
{ {
var result = new List<IPipelineFilterStep>(); var result = new List<IPipelineFilterStep>();
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"); // _logger.LogDebug("Adding colorspace filter");
var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat); var colorspace = new ColorspaceFilter(currentState, videoStream, pixelFormat);

7039
ErsatzTV.Infrastructure.MySql/Migrations/20260127040133_Add_FFmpegProfileNormalizationOptions.Designer.cs generated

File diff suppressed because it is too large Load Diff

51
ErsatzTV.Infrastructure.MySql/Migrations/20260127040133_Add_FFmpegProfileNormalizationOptions.cs

@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfileNormalizationOptions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "NormalizeAudio",
table: "FFmpegProfile",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "NormalizeColors",
table: "FFmpegProfile",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "NormalizeVideo",
table: "FFmpegProfile",
type: "tinyint(1)",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NormalizeAudio",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "NormalizeColors",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "NormalizeVideo",
table: "FFmpegProfile");
}
}
}

15
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -757,6 +757,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<bool>("NormalizeAudio")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("NormalizeColors")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<bool>("NormalizeFramerate") b.Property<bool>("NormalizeFramerate")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)") .HasColumnType("tinyint(1)")
@ -765,6 +775,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("NormalizeLoudnessMode") b.Property<int>("NormalizeLoudnessMode")
.HasColumnType("int"); .HasColumnType("int");
b.Property<bool>("NormalizeVideo")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true);
b.Property<int>("PadMode") b.Property<int>("PadMode")
.HasColumnType("int"); .HasColumnType("int");

6866
ErsatzTV.Infrastructure.Sqlite/Migrations/20260127040052_Add_FFmpegProfileNormalizationOptions.Designer.cs generated

File diff suppressed because it is too large Load Diff

51
ErsatzTV.Infrastructure.Sqlite/Migrations/20260127040052_Add_FFmpegProfileNormalizationOptions.cs

@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_FFmpegProfileNormalizationOptions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "NormalizeAudio",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "NormalizeColors",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "NormalizeVideo",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NormalizeAudio",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "NormalizeColors",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "NormalizeVideo",
table: "FFmpegProfile");
}
}
}

15
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -726,6 +726,16 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("NormalizeAudio")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("NormalizeColors")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("NormalizeFramerate") b.Property<bool>("NormalizeFramerate")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@ -734,6 +744,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("NormalizeLoudnessMode") b.Property<int>("NormalizeLoudnessMode")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("NormalizeVideo")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<int>("PadMode") b.Property<int>("PadMode")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

9
ErsatzTV.Infrastructure/Data/Configurations/FFmpegProfileConfiguration.cs

@ -15,5 +15,14 @@ public class FFmpegProfileConfiguration : IEntityTypeConfiguration<FFmpegProfile
builder.Property(p => p.DeinterlaceVideo) builder.Property(p => p.DeinterlaceVideo)
.HasDefaultValue(true); .HasDefaultValue(true);
builder.Property(p => p.NormalizeAudio)
.HasDefaultValue(true);
builder.Property(p => p.NormalizeVideo)
.HasDefaultValue(true);
builder.Property(p => p.NormalizeColors)
.HasDefaultValue(true);
} }
} }

31
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -203,9 +203,9 @@ public class TranscodingTests
public static HardwareAccelerationKind[] TestAccelerations = public static HardwareAccelerationKind[] TestAccelerations =
[ [
HardwareAccelerationKind.None, HardwareAccelerationKind.None,
//HardwareAccelerationKind.Nvenc HardwareAccelerationKind.Nvenc,
HardwareAccelerationKind.Vaapi, //HardwareAccelerationKind.Vaapi,
HardwareAccelerationKind.Qsv, //HardwareAccelerationKind.Qsv,
// HardwareAccelerationKind.VideoToolbox, // HardwareAccelerationKind.VideoToolbox,
// HardwareAccelerationKind.Amf // HardwareAccelerationKind.Amf
]; ];
@ -217,6 +217,12 @@ public class TranscodingTests
//StreamingMode.HttpLiveStreamingSegmenterV2 //StreamingMode.HttpLiveStreamingSegmenterV2
]; ];
public static bool[] NormalizeColors =
[
false,
true
];
public static string[] FilesToTest => [string.Empty]; public static string[] FilesToTest => [string.Empty];
} }
@ -234,7 +240,9 @@ public class TranscodingTests
[ValueSource(typeof(TestData), nameof(TestData.TestAccelerations))] [ValueSource(typeof(TestData), nameof(TestData.TestAccelerations))]
HardwareAccelerationKind profileAcceleration, HardwareAccelerationKind profileAcceleration,
[ValueSource(typeof(TestData), nameof(TestData.StreamingModes))] [ValueSource(typeof(TestData), nameof(TestData.StreamingModes))]
StreamingMode streamingMode) StreamingMode streamingMode,
[ValueSource(typeof(TestData), nameof(TestData.NormalizeColors))]
bool profileNormalizeColors)
{ {
var localFileSystem = new LocalFileSystem( var localFileSystem = new LocalFileSystem(
new RealFileSystem(), new RealFileSystem(),
@ -303,7 +311,8 @@ public class TranscodingTests
VideoFormat = profileVideoFormat, VideoFormat = profileVideoFormat,
AudioFormat = FFmpegProfileAudioFormat.Aac, AudioFormat = FFmpegProfileAudioFormat.Aac,
DeinterlaceVideo = true, DeinterlaceVideo = true,
BitDepth = profileBitDepth BitDepth = profileBitDepth,
NormalizeColors = profileNormalizeColors
}, },
StreamingMode = streamingMode, StreamingMode = streamingMode,
SubtitleMode = ChannelSubtitleMode.None SubtitleMode = ChannelSubtitleMode.None
@ -423,6 +432,7 @@ public class TranscodingTests
profileBitDepth, profileBitDepth,
profileVideoFormat, profileVideoFormat,
profileAcceleration, profileAcceleration,
profileNormalizeColors,
VaapiDriver.iHD, VaapiDriver.iHD,
localStatisticsProvider, localStatisticsProvider,
streamingMode, streamingMode,
@ -455,7 +465,9 @@ public class TranscodingTests
[ValueSource(typeof(TestData), nameof(TestData.TestAccelerations))] [ValueSource(typeof(TestData), nameof(TestData.TestAccelerations))]
HardwareAccelerationKind profileAcceleration, HardwareAccelerationKind profileAcceleration,
[ValueSource(typeof(TestData), nameof(TestData.StreamingModes))] [ValueSource(typeof(TestData), nameof(TestData.StreamingModes))]
StreamingMode streamingMode) StreamingMode streamingMode,
[ValueSource(typeof(TestData), nameof(TestData.NormalizeColors))]
bool profileNormalizeColors)
{ {
try try
{ {
@ -755,6 +767,7 @@ public class TranscodingTests
profileBitDepth, profileBitDepth,
profileVideoFormat, profileVideoFormat,
profileAcceleration, profileAcceleration,
profileNormalizeColors,
VaapiDriver.iHD, VaapiDriver.iHD,
localStatisticsProvider, localStatisticsProvider,
streamingMode, streamingMode,
@ -1026,6 +1039,7 @@ public class TranscodingTests
FFmpegProfileBitDepth profileBitDepth, FFmpegProfileBitDepth profileBitDepth,
FFmpegProfileVideoFormat profileVideoFormat, FFmpegProfileVideoFormat profileVideoFormat,
HardwareAccelerationKind profileAcceleration, HardwareAccelerationKind profileAcceleration,
bool profileNormalizeColors,
VaapiDriver vaapiDriver, VaapiDriver vaapiDriver,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
StreamingMode streamingMode, StreamingMode streamingMode,
@ -1134,6 +1148,11 @@ public class TranscodingTests
videoStream.PixelFormat.ShouldBe(expectedPixelFormat); videoStream.PixelFormat.ShouldBe(expectedPixelFormat);
if (!profileNormalizeColors)
{
continue;
}
// verify colors // verify colors
var colorParams = new ColorParams( var colorParams = new ColorParams(
videoStream.ColorRange, videoStream.ColorRange,

30
ErsatzTV/Pages/Channels.razor

@ -98,11 +98,25 @@
<div style="align-items: center; display: flex;"> <div style="align-items: center; display: flex;">
@if (_channelsThatCanPreview.Contains(context.Id)) @if (_channelsThatCanPreview.Contains(context.Id))
{ {
<MudTooltip Text="Preview Channel"> Option<FFmpegProfileViewModel> maybeProfile = Optional(_ffmpegProfiles.Find(p => p.Id == context.FFmpegProfileId));
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle" bool notNormalizing = maybeProfile.Any(p => !p.NormalizeVideo || !p.NormalizeAudio);
OnClick="@(_ => PreviewChannel(context))"> if (notNormalizing)
</MudIconButton> {
</MudTooltip> <MudTooltip Text="Preview Channel; audio and/or video may not work without normalization">
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle"
OnClick="@(_ => PreviewChannel(context))"
Style="color: var(--mud-palette-warning-lighten);">
</MudIconButton>
</MudTooltip>
}
else
{
<MudTooltip Text="Preview Channel">
<MudIconButton Icon="@Icons.Material.Filled.PlayCircle"
OnClick="@(_ => PreviewChannel(context))">
</MudIconButton>
</MudTooltip>
}
} }
else if (CanPreviewChannel(context) && _ffmpegProfilesThatCanPreview.ContainsKey(context.FFmpegProfileId) && !_ffmpegProfilesThatCanPreview[context.FFmpegProfileId]) else if (CanPreviewChannel(context) && _ffmpegProfilesThatCanPreview.ContainsKey(context.FFmpegProfileId) && !_ffmpegProfilesThatCanPreview[context.FFmpegProfileId])
{ {
@ -272,6 +286,12 @@
//Console.WriteLine($"Checking video format {videoCodec} and audio format {audioCodec}"); //Console.WriteLine($"Checking video format {videoCodec} and audio format {audioCodec}");
// good luck
if (profile.VideoFormat == FFmpegProfileVideoFormat.Copy || profile.AudioFormat == FFmpegProfileAudioFormat.Copy)
{
return true;
}
if (string.IsNullOrWhiteSpace(videoCodec) || string.IsNullOrWhiteSpace(audioCodec)) if (string.IsNullOrWhiteSpace(videoCodec) || string.IsNullOrWhiteSpace(audioCodec))
{ {
return false; return false;

454
ErsatzTV/Pages/FFmpegEditor.razor

@ -38,266 +38,302 @@
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Preferred Resolution</MudText> <MudText>Normalize Audio</MudText>
</div>
<MudSelect @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
@foreach (ResolutionViewModel resolution in _resolutions)
{
<MudSelectItem Value="@resolution">@resolution.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Scaling Behavior</MudText>
</div> </div>
<MudSelect @bind-Value="_model.ScalingBehavior" For="@(() => _model.ScalingBehavior)"> <MudCheckBox @bind-Value="@_model.NormalizeAudio" For="@(() => _model.NormalizeAudio)" Dense="true"/>
<MudSelectItem Value="@ScalingBehavior.ScaleAndPad">Scale and Pad</MudSelectItem>
<MudSelectItem Value="@ScalingBehavior.Stretch">Stretch</MudSelectItem>
<MudSelectItem Value="@ScalingBehavior.Crop">Crop</MudSelectItem>
</MudSelect>
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Pad Mode</MudText> <MudText>Normalize Video</MudText>
</div> </div>
<MudSelect @bind-Value="_model.PadMode" <MudCheckBox @bind-Value="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)" Dense="true"/>
For="@(() => _model.PadMode)"
Disabled="_model.HardwareAcceleration is not HardwareAccelerationKind.Vaapi"
HelperText="If hardware padding is green, software padding should be used instead">
<MudSelectItem Value="@FilterMode.HardwareIfPossible">Hardware If Possible</MudSelectItem>
<MudSelectItem Value="@FilterMode.Software">Software</MudSelectItem>
</MudSelect>
</MudStack> </MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Video</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Format</MudText> <MudText>Preferred Resolution</MudText>
</div> </div>
<MudSelect @bind-Value="_model.VideoFormat" For="@(() => _model.VideoFormat)"> <MudSelect @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
<MudSelectItem Value="@FFmpegProfileVideoFormat.H264">h264</MudSelectItem> @foreach (ResolutionViewModel resolution in _resolutions)
<MudSelectItem Value="@FFmpegProfileVideoFormat.Hevc">hevc</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileVideoFormat.Mpeg2Video">mpeg-2</MudSelectItem>
@if (_model.HardwareAcceleration is (HardwareAccelerationKind.Nvenc or HardwareAccelerationKind.Vaapi or HardwareAccelerationKind.Qsv))
{ {
<MudSelectItem Value="@FFmpegProfileVideoFormat.Av1">av1</MudSelectItem> <MudSelectItem Value="@resolution">@resolution.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> @if (_model.NormalizeVideo)
<div class="d-flex"> {
<MudText>Profile</MudText> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
</div> <div class="d-flex">
<MudSelect @bind-Value="_model.VideoProfile" <MudText>Scaling Behavior</MudText>
For="@(() => _model.VideoProfile)" </div>
Disabled="@(_model.VideoFormat != FFmpegProfileVideoFormat.H264 || !HardwareAccelSupportsH264Profile(_model.HardwareAcceleration))" <MudSelect @bind-Value="_model.ScalingBehavior" For="@(() => _model.ScalingBehavior)">
Clearable="true"> <MudSelectItem Value="@ScalingBehavior.ScaleAndPad">Scale and Pad</MudSelectItem>
<MudSelectItem Value="@VideoProfile.Main">main</MudSelectItem> <MudSelectItem Value="@ScalingBehavior.Stretch">Stretch</MudSelectItem>
<MudSelectItem Value="@VideoProfile.High">high</MudSelectItem> <MudSelectItem Value="@ScalingBehavior.Crop">Crop</MudSelectItem>
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Nvenc) </MudSelect>
{ </MudStack>
<MudSelectItem Value="@VideoProfile.High444p">high444p</MudSelectItem> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
} <div class="d-flex">
else <MudText>Pad Mode</MudText>
{ </div>
<MudSelectItem Value="@VideoProfile.High10">high10</MudSelectItem> <MudSelect @bind-Value="_model.PadMode"
For="@(() => _model.PadMode)"
Disabled="_model.HardwareAcceleration is not HardwareAccelerationKind.Vaapi"
HelperText="If hardware padding is green, software padding should be used instead">
<MudSelectItem Value="@FilterMode.HardwareIfPossible">Hardware If Possible</MudSelectItem>
<MudSelectItem Value="@FilterMode.Software">Software</MudSelectItem>
</MudSelect>
</MudStack>
}
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Video</MudText>
<MudDivider Class="mb-6"/>
@if (_model.NormalizeVideo)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Format</MudText>
</div>
<MudSelect @bind-Value="_model.VideoFormat" For="@(() => _model.VideoFormat)">
<MudSelectItem Value="@FFmpegProfileVideoFormat.H264">h264</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileVideoFormat.Hevc">hevc</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileVideoFormat.Mpeg2Video">mpeg-2</MudSelectItem>
@if (_model.HardwareAcceleration is (HardwareAccelerationKind.Nvenc or HardwareAccelerationKind.Vaapi or HardwareAccelerationKind.Qsv))
{
<MudSelectItem Value="@FFmpegProfileVideoFormat.Av1">av1</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Profile</MudText>
</div>
<MudSelect @bind-Value="_model.VideoProfile"
For="@(() => _model.VideoProfile)"
Disabled="@(_model.VideoFormat != FFmpegProfileVideoFormat.H264 || !HardwareAccelSupportsH264Profile(_model.HardwareAcceleration))"
Clearable="true">
<MudSelectItem Value="@VideoProfile.Main">main</MudSelectItem>
<MudSelectItem Value="@VideoProfile.High">high</MudSelectItem>
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Nvenc)
{
<MudSelectItem Value="@VideoProfile.High444p">high444p</MudSelectItem>
}
else
{
<MudSelectItem Value="@VideoProfile.High10">high10</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Preset</MudText>
</div>
@{
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
_model.HardwareAcceleration,
_model.VideoFormat,
_model.BitDepth);
} }
</MudSelect> <MudSelect @bind-Value="_model.VideoPreset"
</MudStack> For="@(() => _model.VideoPreset)"
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> Disabled="@(presets.Count == 0)"
<div class="d-flex"> Clearable="true">
<MudText>Preset</MudText> @foreach (string preset in presets)
</div> {
@{ if (!string.IsNullOrWhiteSpace(preset))
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile( {
_model.HardwareAcceleration, <MudSelectItem Value="@preset">@preset</MudSelectItem>
_model.VideoFormat, }
_model.BitDepth); }
} </MudSelect>
<MudSelect @bind-Value="_model.VideoPreset" </MudStack>
For="@(() => _model.VideoPreset)" <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
Disabled="@(presets.Count == 0)" <div class="d-flex">
Clearable="true"> <MudText>Allow B-Frames</MudText>
@foreach (string preset in presets) </div>
{ <MudCheckBox @bind-Value="@_model.AllowBFrames" For="@(() => _model.AllowBFrames)" Dense="true"/>
if (!string.IsNullOrWhiteSpace(preset)) </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Bit Depth</MudText>
</div>
<MudSelect @bind-Value="_model.BitDepth" For="@(() => _model.BitDepth)">
<MudSelectItem Value="@FFmpegProfileBitDepth.EightBit">8-bit</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileBitDepth.TenBit">10-bit</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Bitrate</MudText>
</div>
<MudTextField @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Buffer Size</MudText>
</div>
<MudTextField @bind-Value="_model.VideoBufferSize" For="@(() => _model.VideoBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Hardware Acceleration</MudText>
</div>
<MudSelect @bind-Value="_model.HardwareAcceleration" For="@(() => _model.HardwareAcceleration)">
@foreach (HardwareAccelerationKind hwAccel in _hardwareAccelerationKinds)
{ {
<MudSelectItem Value="@preset">@preset</MudSelectItem> <MudSelectItem Value="@hwAccel">@hwAccel</MudSelectItem>
} }
</MudSelect>
</MudStack>
@if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Vaapi)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>VAAPI Driver</MudText>
</div>
<MudSelect @bind-Value="_model.VaapiDriver" For="@(() => _model.VaapiDriver)">
@foreach (VaapiDriver driver in Enum.GetValues<VaapiDriver>())
{
<MudSelectItem Value="@driver">@driver</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>VAAPI Display</MudText>
</div>
<MudSelect @bind-Value="_model.VaapiDisplay" For="@(() => _model.VaapiDisplay)">
@foreach (string display in _vaapiDisplays)
{
<MudSelectItem Value="@display">@display</MudSelectItem>
}
</MudSelect>
</MudStack>
} }
</MudSelect>
</MudStack> @if (_model.HardwareAcceleration is HardwareAccelerationKind.Vaapi or HardwareAccelerationKind.Qsv)
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Allow B-Frames</MudText>
</div>
<MudCheckBox @bind-Value="@_model.AllowBFrames" For="@(() => _model.AllowBFrames)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Bit Depth</MudText>
</div>
<MudSelect @bind-Value="_model.BitDepth" For="@(() => _model.BitDepth)">
<MudSelectItem Value="@FFmpegProfileBitDepth.EightBit">8-bit</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileBitDepth.TenBit">10-bit</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Bitrate</MudText>
</div>
<MudTextField @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Buffer Size</MudText>
</div>
<MudTextField @bind-Value="_model.VideoBufferSize" For="@(() => _model.VideoBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Hardware Acceleration</MudText>
</div>
<MudSelect @bind-Value="_model.HardwareAcceleration" For="@(() => _model.HardwareAcceleration)">
@foreach (HardwareAccelerationKind hwAccel in _hardwareAccelerationKinds)
{ {
<MudSelectItem Value="@hwAccel">@hwAccel</MudSelectItem> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>@(_model.HardwareAcceleration == HardwareAccelerationKind.Vaapi ? "VAAPI Device" : "QSV Device")</MudText>
</div>
<MudSelect @bind-Value="_model.VaapiDevice" For="@(() => _model.VaapiDevice)">
@foreach (string device in _vaapiDevices)
{
<MudSelectItem Value="@device">@device</MudSelectItem>
}
</MudSelect>
</MudStack>
} }
</MudSelect> }
</MudStack>
@if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) @if (_model.HardwareAcceleration == HardwareAccelerationKind.Qsv)
{
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Vaapi)
{ {
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>VAAPI Driver</MudText> <MudText>QSV Extra Hardware Frames</MudText>
</div> </div>
<MudSelect @bind-Value="_model.VaapiDriver" For="@(() => _model.VaapiDriver)"> <MudTextField @bind-Value="_model.QsvExtraHardwareFrames" For="@(() => _model.QsvExtraHardwareFrames)"/>
@foreach (VaapiDriver driver in Enum.GetValues<VaapiDriver>())
{
<MudSelectItem Value="@driver">@driver</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>VAAPI Display</MudText>
</div>
<MudSelect @bind-Value="_model.VaapiDisplay" For="@(() => _model.VaapiDisplay)">
@foreach (string display in _vaapiDisplays)
{
<MudSelectItem Value="@display">@display</MudSelectItem>
}
</MudSelect>
</MudStack> </MudStack>
} }
else
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Vaapi or HardwareAccelerationKind.Qsv)
{ {
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>@(_model.HardwareAcceleration == HardwareAccelerationKind.Vaapi ? "VAAPI Device" : "QSV Device")</MudText> <MudText>Tonemap Algorithm</MudText>
</div> </div>
<MudSelect @bind-Value="_model.VaapiDevice" For="@(() => _model.VaapiDevice)"> <MudSelect @bind-Value="_model.TonemapAlgorithm" For="@(() => _model.TonemapAlgorithm)">
@foreach (string device in _vaapiDevices) @foreach (FFmpegProfileTonemapAlgorithm algorithm in Enum.GetValues<FFmpegProfileTonemapAlgorithm>())
{ {
<MudSelectItem Value="@device">@device</MudSelectItem> <MudSelectItem Value="@algorithm">@algorithm</MudSelectItem>
} }
</MudSelect> </MudSelect>
</MudStack> </MudStack>
} }
}
@if (_model.HardwareAcceleration == HardwareAccelerationKind.Qsv)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>QSV Extra Hardware Frames</MudText> <MudText>Normalize Frame Rate</MudText>
</div> </div>
<MudTextField @bind-Value="_model.QsvExtraHardwareFrames" For="@(() => _model.QsvExtraHardwareFrames)"/> <MudCheckBox @bind-Value="@_model.NormalizeFramerate" For="@(() => _model.NormalizeFramerate)" Dense="true"/>
</MudStack> </MudStack>
}
else
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Tonemap Algorithm</MudText> <MudText>Normalize Colors</MudText>
</div> </div>
<MudSelect @bind-Value="_model.TonemapAlgorithm" For="@(() => _model.TonemapAlgorithm)"> <MudCheckBox @bind-Value="@_model.NormalizeColors" For="@(() => _model.NormalizeColors)" Dense="true"/>
@foreach (FFmpegProfileTonemapAlgorithm algorithm in Enum.GetValues<FFmpegProfileTonemapAlgorithm>()) </MudStack>
{ <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<MudSelectItem Value="@algorithm">@algorithm</MudSelectItem> <div class="d-flex">
} <MudText>Auto Deinterlace Video</MudText>
</MudSelect> </div>
<MudCheckBox @bind-Value="@_model.DeinterlaceVideo" For="@(() => _model.DeinterlaceVideo)" Dense="true"/>
</MudStack> </MudStack>
} }
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> else
<div class="d-flex"> {
<MudText>Normalize Frame Rate</MudText> <MudText>Video will be copied as-is, including timestamps, which *will* cause issues with most clients</MudText>
</div> }
<MudCheckBox @bind-Value="@_model.NormalizeFramerate" For="@(() => _model.NormalizeFramerate)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Auto Deinterlace Video</MudText>
</div>
<MudCheckBox @bind-Value="@_model.DeinterlaceVideo" For="@(() => _model.DeinterlaceVideo)" Dense="true"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Audio</MudText> <MudText Typo="Typo.h5" Class="mt-10 mb-2">Audio</MudText>
<MudDivider Class="mb-6"/> <MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> @if (_model.NormalizeAudio)
<div class="d-flex">
<MudText>Format</MudText>
</div>
<MudSelect @bind-Value="_model.AudioFormat" For="@(() => _model.AudioFormat)">
<MudSelectItem Value="@FFmpegProfileAudioFormat.Aac">aac</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileAudioFormat.Ac3">ac3</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileAudioFormat.AacLatm">aac (latm)</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Bitrate</MudText>
</div>
<MudTextField @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Buffer Size</MudText>
</div>
<MudTextField @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Channels</MudText>
</div>
<MudTextField @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Sample Rate</MudText>
</div>
<MudTextField @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Normalize Loudness</MudText>
</div>
<MudSelect @bind-Value="_model.NormalizeLoudnessMode" For="@(() => _model.NormalizeLoudnessMode)">
<MudSelectItem Value="@NormalizeLoudnessMode.Off">Off</MudSelectItem>
<MudSelectItem Value="@NormalizeLoudnessMode.LoudNorm">loudnorm</MudSelectItem>
</MudSelect>
</MudStack>
@if (_model.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm)
{ {
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Target Loudness</MudText> <MudText>Format</MudText>
</div>
<MudSelect @bind-Value="_model.AudioFormat" For="@(() => _model.AudioFormat)">
<MudSelectItem Value="@FFmpegProfileAudioFormat.Aac">aac</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileAudioFormat.Ac3">ac3</MudSelectItem>
<MudSelectItem Value="@FFmpegProfileAudioFormat.AacLatm">aac (latm)</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Bitrate</MudText>
</div>
<MudTextField @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Buffer Size</MudText>
</div>
<MudTextField @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Channels</MudText>
</div> </div>
<MudTextField @bind-Value="_model.TargetLoudness" For="@(() => _model.TargetLoudness)" Adornment="Adornment.End" AdornmentText="LUFS"/> <MudTextField @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Sample Rate</MudText>
</div>
<MudTextField @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Normalize Loudness</MudText>
</div>
<MudSelect @bind-Value="_model.NormalizeLoudnessMode" For="@(() => _model.NormalizeLoudnessMode)">
<MudSelectItem Value="@NormalizeLoudnessMode.Off">Off</MudSelectItem>
<MudSelectItem Value="@NormalizeLoudnessMode.LoudNorm">loudnorm</MudSelectItem>
</MudSelect>
</MudStack>
@if (_model.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Target Loudness</MudText>
</div>
<MudTextField @bind-Value="_model.TargetLoudness" For="@(() => _model.TargetLoudness)" Adornment="Adornment.End" AdornmentText="LUFS"/>
</MudStack>
}
}
else
{
<MudText>Audio will be copied as-is, including timestamps, which *will* cause issues with most clients</MudText>
} }
</MudContainer> </MudContainer>
</div> </div>

45
ErsatzTV/ViewModels/FFmpegProfileEditViewModel.cs

@ -23,11 +23,14 @@ public class FFmpegProfileEditViewModel
Id = viewModel.Id; Id = viewModel.Id;
Name = viewModel.Name; Name = viewModel.Name;
NormalizeFramerate = viewModel.NormalizeFramerate; NormalizeFramerate = viewModel.NormalizeFramerate;
NormalizeColors = viewModel.NormalizeColors;
DeinterlaceVideo = viewModel.DeinterlaceVideo; DeinterlaceVideo = viewModel.DeinterlaceVideo;
Resolution = viewModel.Resolution; Resolution = viewModel.Resolution;
ScalingBehavior = viewModel.ScalingBehavior; ScalingBehavior = viewModel.ScalingBehavior;
PadMode = viewModel.PadMode; PadMode = viewModel.PadMode;
ThreadCount = viewModel.ThreadCount; ThreadCount = viewModel.ThreadCount;
NormalizeAudio = viewModel.NormalizeAudio;
NormalizeVideo = viewModel.NormalizeVideo;
HardwareAcceleration = viewModel.HardwareAcceleration; HardwareAcceleration = viewModel.HardwareAcceleration;
VaapiDisplay = viewModel.VaapiDisplay; VaapiDisplay = viewModel.VaapiDisplay;
VaapiDriver = viewModel.VaapiDriver; VaapiDriver = viewModel.VaapiDriver;
@ -46,7 +49,22 @@ public class FFmpegProfileEditViewModel
public int AudioBitrate { get; set; } public int AudioBitrate { get; set; }
public int AudioBufferSize { get; set; } public int AudioBufferSize { get; set; }
public int AudioChannels { get; set; } public int AudioChannels { get; set; }
public FFmpegProfileAudioFormat AudioFormat { get; set; }
public FFmpegProfileAudioFormat AudioFormat
{
get
{
if (field == FFmpegProfileAudioFormat.Copy && NormalizeAudio)
{
return FFmpegProfileAudioFormat.Aac;
}
return field;
}
set;
}
public int AudioSampleRate { get; set; } public int AudioSampleRate { get; set; }
public NormalizeLoudnessMode NormalizeLoudnessMode public NormalizeLoudnessMode NormalizeLoudnessMode
@ -74,6 +92,7 @@ public class FFmpegProfileEditViewModel
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public bool NormalizeFramerate { get; set; } public bool NormalizeFramerate { get; set; }
public bool NormalizeColors { get; set; }
public bool DeinterlaceVideo { get; set; } public bool DeinterlaceVideo { get; set; }
public ResolutionViewModel Resolution { get; set; } public ResolutionViewModel Resolution { get; set; }
public ScalingBehavior ScalingBehavior { get; set; } public ScalingBehavior ScalingBehavior { get; set; }
@ -92,6 +111,8 @@ public class FFmpegProfileEditViewModel
} }
public int ThreadCount { get; set; } public int ThreadCount { get; set; }
public bool NormalizeAudio { get; set; }
public bool NormalizeVideo { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; } public HardwareAccelerationKind HardwareAcceleration { get; set; }
public string VaapiDisplay { get; set; } public string VaapiDisplay { get; set; }
public VaapiDriver VaapiDriver { get; set; } public VaapiDriver VaapiDriver { get; set; }
@ -99,7 +120,21 @@ public class FFmpegProfileEditViewModel
public int? QsvExtraHardwareFrames { get; set; } public int? QsvExtraHardwareFrames { get; set; }
public int VideoBitrate { get; set; } public int VideoBitrate { get; set; }
public int VideoBufferSize { get; set; } public int VideoBufferSize { get; set; }
public FFmpegProfileVideoFormat VideoFormat { get; set; }
public FFmpegProfileVideoFormat VideoFormat
{
get
{
if (field == FFmpegProfileVideoFormat.Copy && NormalizeVideo)
{
return FFmpegProfileVideoFormat.H264;
}
return field;
}
set;
}
public string VideoProfile public string VideoProfile
{ {
@ -124,6 +159,8 @@ public class FFmpegProfileEditViewModel
new( new(
Name, Name,
ThreadCount, ThreadCount,
NormalizeAudio,
NormalizeVideo,
HardwareAcceleration, HardwareAcceleration,
VaapiDisplay, VaapiDisplay,
VaapiDriver, VaapiDriver,
@ -148,6 +185,7 @@ public class FFmpegProfileEditViewModel
AudioChannels, AudioChannels,
AudioSampleRate, AudioSampleRate,
NormalizeFramerate, NormalizeFramerate,
NormalizeColors,
DeinterlaceVideo DeinterlaceVideo
); );
@ -156,6 +194,8 @@ public class FFmpegProfileEditViewModel
Id, Id,
Name, Name,
ThreadCount, ThreadCount,
NormalizeAudio,
NormalizeVideo,
HardwareAcceleration, HardwareAcceleration,
VaapiDisplay, VaapiDisplay,
VaapiDriver, VaapiDriver,
@ -180,6 +220,7 @@ public class FFmpegProfileEditViewModel
AudioChannels, AudioChannels,
AudioSampleRate, AudioSampleRate,
NormalizeFramerate, NormalizeFramerate,
NormalizeColors,
DeinterlaceVideo DeinterlaceVideo
); );
} }

Loading…
Cancel
Save