Browse Source

add watermark support to experimental transcoder logic (#654)

* wip

* fix songs again

* don't use lists of video and audio input files

* test concat command output

* move concat pipeline

* start to use ffmpeg state

* add ffmpeg state and audio state

* audio state is required

* attach input options directly to input files

* move filters to input files

* add watermark support
pull/655/head
Jason Dove 4 years ago committed by GitHub
parent
commit
a21f62ff8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs
  3. 5
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs
  4. 2
      ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs
  5. 5
      ErsatzTV.Application/Watermarks/WatermarkViewModel.cs
  6. 57
      ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs
  7. 26
      ErsatzTV.Core/Domain/ChannelWatermark.cs
  8. 17
      ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs
  9. 201
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  10. 12
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  11. 20
      ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs
  12. 3
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  13. 140
      ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs
  14. 58
      ErsatzTV.FFmpeg/CommandGenerator.cs
  15. 5
      ErsatzTV.FFmpeg/Decoder/AvailableDecoders.cs
  16. 5
      ErsatzTV.FFmpeg/Decoder/DecoderBase.cs
  17. 6
      ErsatzTV.FFmpeg/Decoder/IDecoder.cs
  18. 7
      ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs
  19. 6
      ErsatzTV.FFmpeg/Encoder/EncoderAac.cs
  20. 6
      ErsatzTV.FFmpeg/Encoder/EncoderAc3.cs
  21. 2
      ErsatzTV.FFmpeg/Encoder/EncoderBase.cs
  22. 11
      ErsatzTV.FFmpeg/Environment/FFReportVariable.cs
  23. 2
      ErsatzTV.FFmpeg/Environment/LibvaDriverNameVariable.cs
  24. 5
      ErsatzTV.FFmpeg/FFmpegPipeline.cs
  25. 38
      ErsatzTV.FFmpeg/FFmpegState.cs
  26. 2
      ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs
  27. 19
      ErsatzTV.FFmpeg/Filter/AvailableOverlayFilters.cs
  28. 2
      ErsatzTV.FFmpeg/Filter/BaseFilter.cs
  29. 148
      ErsatzTV.FFmpeg/Filter/ComplexFilter.cs
  30. 14
      ErsatzTV.FFmpeg/Filter/Cuda/OverlayCudaFilter.cs
  31. 21
      ErsatzTV.FFmpeg/Filter/HardwareUploadFilter.cs
  32. 2
      ErsatzTV.FFmpeg/Filter/NormalizeLoudnessFilter.cs
  33. 40
      ErsatzTV.FFmpeg/Filter/OverlayFilter.cs
  34. 19
      ErsatzTV.FFmpeg/Filter/PixelFormatFilter.cs
  35. 34
      ErsatzTV.FFmpeg/Filter/WatermarkFadeFilter.cs
  36. 24
      ErsatzTV.FFmpeg/Filter/WatermarkOpacityFilter.cs
  37. 37
      ErsatzTV.FFmpeg/Filter/WatermarkPixelFormatFilter.cs
  38. 26
      ErsatzTV.FFmpeg/Filter/WatermarkScaleFilter.cs
  39. 8
      ErsatzTV.FFmpeg/Format/ConcatInputFormat.cs
  40. 1
      ErsatzTV.FFmpeg/Format/VideoFormat.cs
  41. 60
      ErsatzTV.FFmpeg/FrameState.cs
  42. 66
      ErsatzTV.FFmpeg/InputFile.cs
  43. 5
      ErsatzTV.FFmpeg/Option/AudioBitrateOutputOption.cs
  44. 5
      ErsatzTV.FFmpeg/Option/AudioBufferSizeOutputOption.cs
  45. 2
      ErsatzTV.FFmpeg/Option/AudioChannelsOutputOption.cs
  46. 3
      ErsatzTV.FFmpeg/Option/AudioSampleRateOutputOption.cs
  47. 21
      ErsatzTV.FFmpeg/Option/DoNotIgnoreLoopInputOption.cs
  48. 1
      ErsatzTV.FFmpeg/Option/HardwareAcceleration/AvailableHardwareAccelerationOptions.cs
  49. 5
      ErsatzTV.FFmpeg/Option/HardwareAcceleration/CudaHardwareAccelerationOption.cs
  50. 2
      ErsatzTV.FFmpeg/Option/HardwareAcceleration/QsvHardwareAccelerationOption.cs
  51. 1
      ErsatzTV.FFmpeg/Option/HardwareAcceleration/VaapiHardwareAccelerationOption.cs
  52. 5
      ErsatzTV.FFmpeg/Option/HardwareAcceleration/VideoToolboxHardwareAccelerationOption.cs
  53. 8
      ErsatzTV.FFmpeg/Option/IInputOption.cs
  54. 30
      ErsatzTV.FFmpeg/Option/InfiniteLoopInputOption.cs
  55. 2
      ErsatzTV.FFmpeg/Option/Metadata/DoNotMapMetadataOutputOption.cs
  56. 5
      ErsatzTV.FFmpeg/Option/Metadata/MetadataAudioLanguageOutputOption.cs
  57. 5
      ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceNameOutputOption.cs
  58. 5
      ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceProviderOutputOption.cs
  59. 2
      ErsatzTV.FFmpeg/Option/OutputTsOffsetOption.cs
  60. 22
      ErsatzTV.FFmpeg/Option/PixelFormatOutputOption.cs
  61. 12
      ErsatzTV.FFmpeg/Option/RealtimeInputOption.cs
  62. 18
      ErsatzTV.FFmpeg/Option/StreamSeekInputOption.cs
  63. 3
      ErsatzTV.FFmpeg/Option/TimeLimitOutputOption.cs
  64. 9
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs
  65. 478
      ErsatzTV.FFmpeg/PipelineBuilder.cs
  66. 12
      ErsatzTV.FFmpeg/State/AudioState.cs
  67. 42
      ErsatzTV.FFmpeg/State/WatermarkState.cs
  68. 27
      ErsatzTV/Pages/WatermarkEditor.razor
  69. 3
      ErsatzTV/Validators/WatermarkEditViewModelValidator.cs
  70. 5
      ErsatzTV/ViewModels/WatermarkEditViewModel.cs

1
CHANGELOG.md

@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- Disable framerate normalization by default and on all ffmpeg profiles
- If framerate normalization is desired (not typically needed), it can be re-enabled manually
- Show watermarks over songs
## [0.4.1-alpha] - 2022-02-10
### Fixed

5
ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using MediatR;
@ -10,8 +11,8 @@ namespace ErsatzTV.Application.Watermarks.Commands @@ -10,8 +11,8 @@ namespace ErsatzTV.Application.Watermarks.Commands
string Image,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
ChannelWatermarkLocation Location,
ChannelWatermarkSize Size,
WatermarkLocation Location,
WatermarkSize Size,
int Width,
int HorizontalMargin,
int VerticalMargin,

5
ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using MediatR;
@ -11,8 +12,8 @@ namespace ErsatzTV.Application.Watermarks.Commands @@ -11,8 +12,8 @@ namespace ErsatzTV.Application.Watermarks.Commands
string Image,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
ChannelWatermarkLocation Location,
ChannelWatermarkSize Size,
WatermarkLocation Location,
WatermarkSize Size,
int Width,
int HorizontalMargin,
int VerticalMargin,

2
ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs

@ -21,7 +21,7 @@ namespace ErsatzTV.Application.Watermarks.Commands @@ -21,7 +21,7 @@ namespace ErsatzTV.Application.Watermarks.Commands
UpdateWatermark request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ChannelWatermark> validation = await Validate(dbContext, request);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
}

5
ErsatzTV.Application/Watermarks/WatermarkViewModel.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.Application.Watermarks
{
@ -8,8 +9,8 @@ namespace ErsatzTV.Application.Watermarks @@ -8,8 +9,8 @@ namespace ErsatzTV.Application.Watermarks
string Name,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
ChannelWatermarkLocation Location,
ChannelWatermarkSize Size,
WatermarkLocation Location,
WatermarkSize Size,
int Width,
int HorizontalMargin,
int VerticalMargin,

57
ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.FFmpeg.State;
using FluentAssertions;
using LanguageExt;
using NUnit.Framework;
@ -141,7 +142,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -141,7 +142,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.BottomLeft,
WatermarkLocation.BottomLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=H-h-54[v]",
@ -151,7 +152,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -151,7 +152,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.BottomRight,
WatermarkLocation.BottomRight,
false,
100,
"[0:0][1:v]overlay=x=W-w-134:y=H-h-54[v]",
@ -161,7 +162,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -161,7 +162,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=54[v]",
@ -171,7 +172,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -171,7 +172,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.TopRight,
WatermarkLocation.TopRight,
false,
100,
"[0:0][1:v]overlay=x=W-w-134:y=54[v]",
@ -181,7 +182,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -181,7 +182,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
true,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
100,
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,fade=in:st=300:d=1:alpha=1:enable='between(t,0,314)',fade=out:st=315:d=1:alpha=1:enable='between(t,301,899)',fade=in:st=900:d=1:alpha=1:enable='between(t,316,914)',fade=out:st=915:d=1:alpha=1:enable='between(t,901,1499)',fade=in:st=1500:d=1:alpha=1:enable='between(t,916,1514)',fade=out:st=1515:d=1:alpha=1:enable='between(t,1501,2099)',fade=in:st=2100:d=1:alpha=1:enable='between(t,1516,2114)',fade=out:st=2115:d=1:alpha=1:enable='between(t,2101,2699)',fade=in:st=2700:d=1:alpha=1:enable='between(t,2116,2714)',fade=out:st=2715:d=1:alpha=1:enable='between(t,2701,3300)'[wmp];[0:0][wmp]overlay=x=134:y=54,format=nv12[v]",
@ -191,7 +192,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -191,7 +192,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
true,
100,
"[1:v]scale=384:-1[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
@ -201,7 +202,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -201,7 +202,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
90,
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.90[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
@ -211,7 +212,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -211,7 +212,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
true,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
100,
"[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
@ -221,7 +222,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -221,7 +222,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
true,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
true,
100,
"[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
@ -231,7 +232,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -231,7 +232,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
@ -241,7 +242,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -241,7 +242,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0][1:v]overlay=x=134:y=54[v]",
@ -251,7 +252,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -251,7 +252,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
bool alignAudio,
bool deinterlace,
bool intermittent,
ChannelWatermarkLocation location,
WatermarkLocation location,
bool scaled,
int opacity,
string expectedVideoFilter,
@ -266,7 +267,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -266,7 +267,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DurationSeconds = intermittent ? 15 : 0,
FrequencyMinutes = intermittent ? 10 : 0,
Location = location,
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
Size = scaled ? WatermarkSize.Scaled : WatermarkSize.ActualSize,
WidthPercent = scaled ? 20 : 0,
Opacity = opacity,
HorizontalMarginPercent = 7,
@ -310,7 +311,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -310,7 +311,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.BottomLeft,
WatermarkLocation.BottomLeft,
false,
100,
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=H-h-54[v]",
@ -321,7 +322,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -321,7 +322,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.BottomLeft,
WatermarkLocation.BottomLeft,
false,
100,
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=H-h-54,hwupload[v]",
@ -332,7 +333,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -332,7 +333,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
true,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
100,
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,fade=in:st=300:d=1:alpha=1:enable='between(t,0,314)',fade=out:st=315:d=1:alpha=1:enable='between(t,301,899)',fade=in:st=900:d=1:alpha=1:enable='between(t,316,914)',fade=out:st=915:d=1:alpha=1:enable='between(t,901,1499)',fade=in:st=1500:d=1:alpha=1:enable='between(t,916,1514)',fade=out:st=1515:d=1:alpha=1:enable='between(t,1501,2099)',fade=in:st=2100:d=1:alpha=1:enable='between(t,1516,2114)',fade=out:st=2115:d=1:alpha=1:enable='between(t,2101,2699)',fade=in:st=2700:d=1:alpha=1:enable='between(t,2116,2714)',fade=out:st=2715:d=1:alpha=1:enable='between(t,2701,3300)',hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
@ -343,7 +344,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -343,7 +344,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
true,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
100,
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,fade=in:st=300:d=1:alpha=1:enable='between(t,0,314)',fade=out:st=315:d=1:alpha=1:enable='between(t,301,899)',fade=in:st=900:d=1:alpha=1:enable='between(t,316,914)',fade=out:st=915:d=1:alpha=1:enable='between(t,901,1499)',fade=in:st=1500:d=1:alpha=1:enable='between(t,916,1514)',fade=out:st=1515:d=1:alpha=1:enable='between(t,1501,2099)',fade=in:st=2100:d=1:alpha=1:enable='between(t,1516,2114)',fade=out:st=2115:d=1:alpha=1:enable='between(t,2101,2699)',fade=in:st=2700:d=1:alpha=1:enable='between(t,2116,2714)',fade=out:st=2715:d=1:alpha=1:enable='between(t,2701,3300)',hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
@ -354,7 +355,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -354,7 +355,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
true,
100,
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,scale=384:-1,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
@ -365,7 +366,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -365,7 +366,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
true,
100,
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,scale=384:-1,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
@ -376,7 +377,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -376,7 +377,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
90,
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,colorchannelmixer=aa=0.90,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
@ -387,7 +388,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -387,7 +388,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
90,
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,colorchannelmixer=aa=0.90,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
@ -399,7 +400,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -399,7 +400,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
// false,
// true,
// false,
// ChannelWatermarkLocation.TopLeft,
// WatermarkLocation.TopLeft,
// false,
// 100,
// "[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
@ -409,7 +410,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -409,7 +410,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
// false,
// true,
// false,
// ChannelWatermarkLocation.TopLeft,
// WatermarkLocation.TopLeft,
// true,
// 100,
// "[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
@ -419,7 +420,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -419,7 +420,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
// true,
// true,
// false,
// ChannelWatermarkLocation.TopLeft,
// WatermarkLocation.TopLeft,
// false,
// 100,
// "[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
@ -429,7 +430,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -429,7 +430,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
@ -440,7 +441,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -440,7 +441,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
false,
false,
ChannelWatermarkLocation.TopLeft,
WatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
@ -451,7 +452,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -451,7 +452,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
bool alignAudio,
bool deinterlace,
bool intermittent,
ChannelWatermarkLocation location,
WatermarkLocation location,
bool scaled,
int opacity,
string expectedVideoFilter,
@ -467,7 +468,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -467,7 +468,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DurationSeconds = intermittent ? 15 : 0,
FrequencyMinutes = intermittent ? 10 : 0,
Location = location,
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
Size = scaled ? WatermarkSize.Scaled : WatermarkSize.ActualSize,
WidthPercent = scaled ? 20 : 0,
Opacity = opacity,
HorizontalMarginPercent = 7,

26
ErsatzTV.Core/Domain/ChannelWatermark.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
namespace ErsatzTV.Core.Domain
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.Core.Domain
{
public class ChannelWatermark
{
@ -7,8 +9,8 @@ @@ -7,8 +9,8 @@
public ChannelWatermarkMode Mode { get; set; }
public ChannelWatermarkImageSource ImageSource { get; set; }
public string Image { get; set; }
public ChannelWatermarkLocation Location { get; set; }
public ChannelWatermarkSize Size { get; set; }
public WatermarkLocation Location { get; set; }
public WatermarkSize Size { get; set; }
public int WidthPercent { get; set; }
public int HorizontalMarginPercent { get; set; }
public int VerticalMarginPercent { get; set; }
@ -17,24 +19,6 @@ @@ -17,24 +19,6 @@
public int Opacity { get; set; }
}
public enum ChannelWatermarkLocation
{
BottomRight = 0,
BottomLeft = 1,
TopRight = 2,
TopLeft = 3,
TopMiddle = 4,
RightMiddle = 5,
BottomMiddle = 6,
LeftMiddle = 7
}
public enum ChannelWatermarkSize
{
Scaled = 0,
ActualSize = 1
}
public enum ChannelWatermarkMode
{
None = 0,

17
ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs

@ -6,6 +6,7 @@ using System.Runtime.InteropServices; @@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using static LanguageExt.Prelude;
@ -330,13 +331,13 @@ namespace ErsatzTV.Core.FFmpeg @@ -330,13 +331,13 @@ namespace ErsatzTV.Core.FFmpeg
string position = watermark.Location switch
{
ChannelWatermarkLocation.BottomLeft => $"x={horizontalMargin}:y=H-h-{verticalMargin}",
ChannelWatermarkLocation.TopLeft => $"x={horizontalMargin}:y={verticalMargin}",
ChannelWatermarkLocation.TopRight => $"x=W-w-{horizontalMargin}:y={verticalMargin}",
ChannelWatermarkLocation.TopMiddle => $"x=(W-w)/2:y={verticalMargin}",
ChannelWatermarkLocation.RightMiddle => $"x=W-w-{horizontalMargin}:y=(H-h)/2",
ChannelWatermarkLocation.BottomMiddle => $"x=(W-w)/2:y=H-h-{verticalMargin}",
ChannelWatermarkLocation.LeftMiddle => $"x={horizontalMargin}:y=(H-h)/2",
WatermarkLocation.BottomLeft => $"x={horizontalMargin}:y=H-h-{verticalMargin}",
WatermarkLocation.TopLeft => $"x={horizontalMargin}:y={verticalMargin}",
WatermarkLocation.TopRight => $"x=W-w-{horizontalMargin}:y={verticalMargin}",
WatermarkLocation.TopMiddle => $"x=(W-w)/2:y={verticalMargin}",
WatermarkLocation.RightMiddle => $"x=W-w-{horizontalMargin}:y=(H-h)/2",
WatermarkLocation.BottomMiddle => $"x=(W-w)/2:y=H-h-{verticalMargin}",
WatermarkLocation.LeftMiddle => $"x={horizontalMargin}:y=(H-h)/2",
_ => $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}"
};
@ -346,7 +347,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -346,7 +347,7 @@ namespace ErsatzTV.Core.FFmpeg
watermarkPreprocess.Add($"colorchannelmixer=aa={opacity:F2}");
}
if (watermark.Size == ChannelWatermarkSize.Scaled)
if (watermark.Size == WatermarkSize.Scaled)
{
double width = Math.Round(watermark.WidthPercent / 100.0 * _resolution.Width);
watermarkPreprocess.Add($"scale={width}:-1");

201
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -12,9 +12,11 @@ using ErsatzTV.FFmpeg; @@ -12,9 +12,11 @@ using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using FFmpegState = ErsatzTV.FFmpeg.FFmpegState;
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
namespace ErsatzTV.Core.FFmpeg;
@ -75,6 +77,32 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -75,6 +77,32 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
hlsRealtime,
targetFramerate);
Option<WatermarkOptions> watermarkOptions =
await _ffmpegProcessService.GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
Option<List<FadePoint>> maybeFadePoints = watermarkOptions
.Map(o => o.Watermark)
.Flatten()
.Where(wm => wm.Mode == ChannelWatermarkMode.Intermittent)
.Map(
wm =>
WatermarkCalculator.CalculateFadePoints(
start,
inPoint,
outPoint,
playbackSettings.StreamSeek,
wm.FrequencyMinutes,
wm.DurationSeconds));
var audioState = new AudioState(
playbackSettings.AudioCodec,
playbackSettings.AudioChannels,
playbackSettings.AudioBitrate,
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None,
playbackSettings.NormalizeLoudness);
var ffmpegVideoStream = new VideoStream(
videoStream.Index,
videoStream.Codec,
@ -83,25 +111,17 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -83,25 +111,17 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoVersion.RFrameRate,
videoPath != audioPath); // still image when paths are different
var inputFiles = new List<InputFile>
{
new(videoPath, new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegVideoStream })
};
var videoInputFile = new VideoInputFile(videoPath, new List<VideoStream> { ffmpegVideoStream });
foreach (MediaStream audioStream in maybeAudioStream)
{
var ffmpegAudioStream = new AudioStream(audioStream.Index, audioStream.Codec, audioStream.Channels);
if (videoPath == audioPath)
{
inputFiles.Head().Streams.Add(ffmpegAudioStream);
}
else
Option<AudioInputFile> audioInputFile = maybeAudioStream.Map(
audioStream =>
{
inputFiles.Add(new InputFile(audioPath, new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegAudioStream }));
}
}
var ffmpegAudioStream = new AudioStream(audioStream.Index, audioStream.Codec, audioStream.Channels);
return new AudioInputFile(audioPath, new List<AudioStream> { ffmpegAudioStream }, audioState);
});
var watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints);
// TODO: need formats for these codecs
string videoFormat = playbackSettings.VideoCodec switch
{
@ -133,17 +153,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -133,17 +153,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts")
: Option<string>.None;
// normalize songs to yuv420p
Option<IPixelFormat> desiredPixelFormat =
videoPath == audioPath ? ffmpegVideoStream.PixelFormat : new PixelFormatYuv420P();
var desiredState = new FrameState(
saveReports,
hwAccel,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
playbackSettings.RealtimeOutput,
false,
playbackSettings.StreamSeek,
finish - now,
false, // TODO: fallback filler needs to loop
videoFormat,
ffmpegVideoStream.PixelFormat,
desiredPixelFormat,
await playbackSettings.ScaledSize.Map(ss => new FrameSize(ss.Width, ss.Height))
.IfNoneAsync(new FrameSize(videoVersion.Width, videoVersion.Height)),
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height),
@ -151,14 +169,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -151,14 +169,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale,
playbackSettings.Deinterlace,
playbackSettings.AudioCodec,
playbackSettings.AudioChannels,
playbackSettings.AudioBitrate,
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None,
playbackSettings.NormalizeLoudness,
playbackSettings.Deinterlace);
var ffmpegState = new FFmpegState(
saveReports,
hwAccel,
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
playbackSettings.StreamSeek,
finish - now,
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
"ErsatzTV",
channel.Name,
@ -169,8 +188,78 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -169,8 +188,78 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
ptsOffset);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
var pipelineBuilder = new PipelineBuilder(
videoInputFile,
audioInputFile,
watermarkInputFile,
FileSystemLayout.FFmpegReportsFolder,
_logger);
return GetProcess(ffmpegPath, inputFiles, desiredState);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
return GetProcess(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline);
}
private Option<WatermarkInputFile> GetWatermarkInputFile(
Option<WatermarkOptions> watermarkOptions,
Option<List<FadePoint>> maybeFadePoints)
{
foreach (WatermarkOptions options in watermarkOptions)
{
foreach (ChannelWatermark watermark in options.Watermark)
{
// skip watermark if intermittent and no fade points
if (watermark.Mode != ChannelWatermarkMode.None &&
(watermark.Mode != ChannelWatermarkMode.Intermittent ||
maybeFadePoints.Map(fp => fp.Count > 0).IfNone(false)))
{
foreach (string path in options.ImagePath)
{
var watermarkInputFile = new WatermarkInputFile(
path,
new List<VideoStream>
{
new(
options.ImageStreamIndex.IfNone(0),
"unknown",
new PixelFormatUnknown(),
new FrameSize(1, 1),
Option<string>.None,
!options.IsAnimated)
},
new WatermarkState(
maybeFadePoints.Map(
lst => lst.Map(
fp =>
{
return fp switch
{
FadeInPoint fip => (WatermarkFadePoint)new WatermarkFadeIn(
fip.Time,
fip.EnableStart,
fip.EnableFinish),
FadeOutPoint fop => new WatermarkFadeOut(
fop.Time,
fop.EnableStart,
fop.EnableFinish),
_ => throw new NotSupportedException() // this will never happen
};
}).ToList()),
watermark.Location,
watermark.Size,
watermark.WidthPercent,
watermark.HorizontalMarginPercent,
watermark.VerticalMarginPercent,
watermark.Opacity));
return watermarkInputFile;
}
}
}
}
return None;
}
public Task<Process> ForError(
@ -185,14 +274,18 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -185,14 +274,18 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
public Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
var desiredState = FrameState.Concat(saveReports, channel.Name, resolution);
var inputFiles = new List<InputFile>
{
new ConcatInputFile($"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}", resolution)
};
var concatInputFile = new ConcatInputFile(
$"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}",
resolution);
var pipelineBuilder = new PipelineBuilder(None, None, None, FileSystemLayout.FFmpegReportsFolder, _logger);
FFmpegPipeline pipeline = pipelineBuilder.Concat(
concatInputFile,
FFmpegState.Concat(saveReports, channel.Name));
return GetProcess(ffmpegPath, inputFiles, desiredState);
return GetProcess(ffmpegPath, None, None, None, concatInputFile, pipeline);
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
@ -213,7 +306,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -213,7 +306,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
string videoPath,
bool boxBlur,
Option<string> watermarkPath,
ChannelWatermarkLocation watermarkLocation,
WatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent) =>
@ -231,15 +324,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -231,15 +324,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
verticalMarginPercent,
watermarkWidthPercent);
private Process GetProcess(string ffmpegPath, IList<InputFile> inputFiles, FrameState desiredState)
private Process GetProcess(
string ffmpegPath,
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<ConcatInputFile> concatInputFile,
FFmpegPipeline pipeline)
{
var pipelineBuilder = new PipelineBuilder(inputFiles, FileSystemLayout.FFmpegReportsFolder, _logger);
FFmpegPipeline pipeline = pipelineBuilder.Build(desiredState);
IEnumerable<string> loggedSteps = pipeline.PipelineSteps.Map(ps => ps.GetType().Name);
IEnumerable<string> loggedVideoFilters = pipeline.VideoFilterSteps.Map(vf => vf.GetType().Name);
IEnumerable<string> loggedAudioFilters = pipeline.AudioFilterSteps.Map(af => af.GetType().Name);
IEnumerable<string> loggedVideoFilters =
videoInputFile.Map(f => f.FilterSteps.Map(vf => vf.GetType().Name)).Flatten();
IEnumerable<string> loggedAudioFilters =
audioInputFile.Map(f => f.FilterSteps.Map(af => af.GetType().Name)).Flatten();
_logger.LogDebug(
"FFmpeg pipeline {PipelineSteps}, {AudioFilters}, {VideoFilters}",
@ -248,8 +345,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -248,8 +345,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
loggedVideoFilters
);
IList<EnvironmentVariable> environmentVariables = CommandGenerator.GenerateEnvironmentVariables(pipeline.PipelineSteps);
IList<string> arguments = CommandGenerator.GenerateArguments(inputFiles, pipeline.PipelineSteps);
IList<EnvironmentVariable> environmentVariables =
CommandGenerator.GenerateEnvironmentVariables(pipeline.PipelineSteps);
IList<string> arguments = CommandGenerator.GenerateArguments(
videoInputFile,
audioInputFile,
watermarkInputFile,
concatInputFile,
pipeline.PipelineSteps);
var startInfo = new ProcessStartInfo
{

12
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain; @@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
@ -330,7 +331,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -330,7 +331,7 @@ namespace ErsatzTV.Core.FFmpeg
string videoPath,
bool boxBlur,
Option<string> watermarkPath,
ChannelWatermarkLocation watermarkLocation,
WatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent)
@ -349,7 +350,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -349,7 +350,7 @@ namespace ErsatzTV.Core.FFmpeg
HorizontalMarginPercent = horizontalMarginPercent,
VerticalMarginPercent = verticalMarginPercent,
Location = watermarkLocation,
Size = ChannelWatermarkSize.Scaled,
Size = WatermarkSize.Scaled,
WidthPercent = watermarkWidthPercent,
Opacity = 100
}
@ -421,18 +422,13 @@ namespace ErsatzTV.Core.FFmpeg @@ -421,18 +422,13 @@ namespace ErsatzTV.Core.FFmpeg
private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) =>
displaySize.Width != target.Width || displaySize.Height != target.Height;
private async Task<WatermarkOptions> GetWatermarkOptions(
internal async Task<WatermarkOptions> GetWatermarkOptions(
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
Option<ChannelWatermark> watermarkOverride,
Option<string> watermarkPath)
{
if (videoVersion is BackgroundImageMediaVersion)
{
return new WatermarkOptions(None, None, None, false);
}
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo)
{

20
ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs

@ -7,6 +7,8 @@ using System.Threading.Tasks; @@ -7,6 +7,8 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using static LanguageExt.Prelude;
@ -71,9 +73,9 @@ namespace ErsatzTV.Core.FFmpeg @@ -71,9 +73,9 @@ namespace ErsatzTV.Core.FFmpeg
const int HORIZONTAL_MARGIN_PERCENT = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
const int WATERMARK_WIDTH_PERCENT = 25;
ChannelWatermarkLocation watermarkLocation = NextRandom(2) == 0
? ChannelWatermarkLocation.BottomLeft
: ChannelWatermarkLocation.BottomRight;
WatermarkLocation watermarkLocation = NextRandom(2) == 0
? WatermarkLocation.BottomLeft
: WatermarkLocation.BottomRight;
foreach (SongMetadata metadata in song.SongMetadata)
{
@ -120,10 +122,10 @@ namespace ErsatzTV.Core.FFmpeg @@ -120,10 +122,10 @@ namespace ErsatzTV.Core.FFmpeg
{
switch (watermarkLocation)
{
case ChannelWatermarkLocation.BottomLeft:
case WatermarkLocation.BottomLeft:
leftMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
case ChannelWatermarkLocation.BottomRight:
case WatermarkLocation.BottomRight:
leftMarginPercent = rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
rightMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
@ -235,7 +237,13 @@ namespace ErsatzTV.Core.FFmpeg @@ -235,7 +237,13 @@ namespace ErsatzTV.Core.FFmpeg
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0, PixelFormat = "yuv420p" },
new()
{
MediaStreamKind = MediaStreamKind.Video,
Index = 0,
Codec = VideoFormat.GeneratedImage,
PixelFormat = new PixelFormatUnknown().Name // the resulting pixel format is unknown
},
},
MediaFiles = new List<MediaFile>
{

3
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -4,6 +4,7 @@ using System.Threading.Tasks; @@ -4,6 +4,7 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
@ -56,7 +57,7 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg @@ -56,7 +57,7 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
string videoPath,
bool boxBlur,
Option<string> watermarkPath,
ChannelWatermarkLocation watermarkLocation,
WatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent);

140
ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs

@ -1,44 +1,47 @@ @@ -1,44 +1,47 @@
using System;
using System.Collections.Generic;
using ErsatzTV.FFmpeg.Encoder;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.State;
using NUnit.Framework;
using FluentAssertions;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Moq;
using static LanguageExt.Prelude;
namespace ErsatzTV.FFmpeg.Tests;
[TestFixture]
public class PipelineGeneratorTests
{
private readonly ILogger _logger = new Mock<ILogger>().Object;
private readonly ILogger _logger = new Mock<ILogger>().Object;
[Test]
[Ignore("These aren't useful yet")]
public void Correct_Codecs_And_Pixel_Format_Should_Copy()
public void Incorrect_Video_Codec_Should_Use_Encoder()
{
var testFile = new InputFile(
var videoInputFile = new VideoInputFile(
"/tmp/whatever.mkv",
new List<MediaStream>
{
new VideoStream(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false),
new AudioStream(1, AudioFormat.Aac, 2)
});
new List<VideoStream>
{ new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) });
var inputFiles = new List<InputFile> { testFile };
var audioInputFile = new AudioInputFile(
"/tmp/whatever.mkv",
new List<AudioStream> { new(1, AudioFormat.Aac, 2) },
new AudioState(
AudioFormat.Aac,
2,
320,
640,
48,
Option<TimeSpan>.None,
false));
var desiredState = new FrameState(
false,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
true,
false,
Option<TimeSpan>.None,
Option<TimeSpan>.None,
VideoFormat.H264,
VideoFormat.Hevc,
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
@ -46,70 +49,15 @@ public class PipelineGeneratorTests @@ -46,70 +49,15 @@ public class PipelineGeneratorTests
2000,
4000,
90_000,
false,
AudioFormat.Aac,
2,
320,
640,
48,
Option<TimeSpan>.None,
false,
false,
Option<string>.None,
Option<string>.None,
Option<string>.None,
OutputFormatKind.MpegTs,
Option<string>.None,
Option<string>.None,
0);
var builder = new PipelineBuilder(inputFiles, "", _logger);
FFmpegPipeline result = builder.Build(desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0);
PrintCommand(inputFiles, result);
}
[Test]
[Ignore("These aren't useful yet")]
public void Incorrect_Video_Codec_Should_Use_Encoder()
{
var testFile = new InputFile(
"/tmp/whatever.mkv",
new List<MediaStream>
{
new VideoStream(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false),
new AudioStream(1, AudioFormat.Aac, 2)
});
var inputFiles = new List<InputFile> { testFile };
false);
var desiredState = new FrameState(
var ffmpegState = new FFmpegState(
false,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
true,
false,
Option<TimeSpan>.None,
Option<TimeSpan>.None,
VideoFormat.Hevc,
new PixelFormatYuv420P(),
new FrameSize(1920, 1080),
new FrameSize(1920, 1080),
Option<int>.None,
2000,
4000,
90_000,
false,
AudioFormat.Aac,
2,
320,
640,
48,
Option<TimeSpan>.None,
false,
false,
Option<string>.None,
Option<string>.None,
@ -119,36 +67,50 @@ public class PipelineGeneratorTests @@ -119,36 +67,50 @@ public class PipelineGeneratorTests
Option<string>.None,
0);
var builder = new PipelineBuilder(inputFiles, "", _logger);
FFmpegPipeline result = builder.Build(desiredState);
var builder = new PipelineBuilder(videoInputFile, audioInputFile, None, "", _logger);
FFmpegPipeline result = builder.Build(ffmpegState, desiredState);
result.PipelineSteps.Should().HaveCountGreaterThan(0);
result.PipelineSteps.Should().Contain(ps => ps is EncoderLibx265);
PrintCommand(inputFiles, result);
PrintCommand(videoInputFile, audioInputFile, None, None, result);
}
[Test]
public void Concat_Test()
{
var resolution = new FrameSize(1920, 1080);
var desiredState = FrameState.Concat(false, "Some Channel", resolution);
var inputFiles = new List<InputFile>
{
new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", resolution)
};
var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", resolution);
var builder = new PipelineBuilder(inputFiles, "", _logger);
FFmpegPipeline result = builder.Build(desiredState);
var builder = new PipelineBuilder(None, None, None, "", _logger);
FFmpegPipeline result = builder.Concat(concatInputFile, FFmpegState.Concat(false, "Some Channel"));
result.PipelineSteps.Should().HaveCountGreaterThan(0);
PrintCommand(inputFiles, result);
string command = PrintCommand(None, None, None, concatInputFile, result);
command.Should().Be(
"-threads 1 -nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -re -stream_loop -1 -i http://localhost:8080/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1");
}
private static void PrintCommand(IEnumerable<InputFile> inputFiles, FFmpegPipeline pipeline)
private static string PrintCommand(
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
Option<ConcatInputFile> concatInputFile,
FFmpegPipeline pipeline)
{
IList<string> arguments = CommandGenerator.GenerateArguments(inputFiles, pipeline.PipelineSteps);
IList<string> arguments = CommandGenerator.GenerateArguments(
videoInputFile,
audioInputFile,
watermarkInputFile,
concatInputFile,
pipeline.PipelineSteps);
var command = string.Join(" ", arguments);
Console.WriteLine($"Generated command: ffmpeg {string.Join(" ", arguments)}");
return command;
}
}

58
ErsatzTV.FFmpeg/CommandGenerator.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Option;
using LanguageExt;
namespace ErsatzTV.FFmpeg;
@ -10,7 +12,10 @@ public static class CommandGenerator @@ -10,7 +12,10 @@ public static class CommandGenerator
}
public static IList<string> GenerateArguments(
IEnumerable<InputFile> inputFiles,
Option<VideoInputFile> maybeVideoInputFile,
Option<AudioInputFile> maybeAudioInputFile,
Option<WatermarkInputFile> maybeWatermarkInputFile,
Option<ConcatInputFile> maybeConcatInputFile,
IList<IPipelineStep> pipelineSteps)
{
var arguments = new List<string>();
@ -20,14 +25,57 @@ public static class CommandGenerator @@ -20,14 +25,57 @@ public static class CommandGenerator
arguments.AddRange(step.GlobalOptions);
}
foreach (InputFile inputFile in inputFiles)
var includedPaths = new System.Collections.Generic.HashSet<string>();
foreach (VideoInputFile videoInputFile in maybeVideoInputFile)
{
foreach (IPipelineStep step in pipelineSteps)
includedPaths.Add(videoInputFile.Path);
foreach (IInputOption step in videoInputFile.InputOptions)
{
arguments.AddRange(step.InputOptions(inputFile));
arguments.AddRange(step.InputOptions(videoInputFile));
}
arguments.AddRange(new[] { "-i", inputFile.Path });
arguments.AddRange(new[] { "-i", videoInputFile.Path });
}
foreach (AudioInputFile audioInputFile in maybeAudioInputFile)
{
if (!includedPaths.Contains(audioInputFile.Path))
{
includedPaths.Add(audioInputFile.Path);
foreach (IInputOption step in audioInputFile.InputOptions)
{
arguments.AddRange(step.InputOptions(audioInputFile));
}
arguments.AddRange(new[] { "-i", audioInputFile.Path });
}
}
foreach (WatermarkInputFile watermarkInputFile in maybeWatermarkInputFile)
{
if (!includedPaths.Contains(watermarkInputFile.Path))
{
includedPaths.Add(watermarkInputFile.Path);
foreach (IInputOption step in watermarkInputFile.InputOptions)
{
arguments.AddRange(step.InputOptions(watermarkInputFile));
}
arguments.AddRange(new[] { "-i", watermarkInputFile.Path });
}
}
foreach (ConcatInputFile concatInputFile in maybeConcatInputFile)
{
foreach (IInputOption step in concatInputFile.InputOptions)
{
arguments.AddRange(step.InputOptions(concatInputFile));
}
arguments.AddRange(new[] { "-i", concatInputFile.Path });
}
foreach (IPipelineStep step in pipelineSteps)

5
ErsatzTV.FFmpeg/Decoder/AvailableDecoders.cs

@ -8,9 +8,9 @@ namespace ErsatzTV.FFmpeg.Decoder; @@ -8,9 +8,9 @@ namespace ErsatzTV.FFmpeg.Decoder;
public static class AvailableDecoders
{
public static Option<IDecoder> ForVideoFormat(FrameState currentState, FrameState desiredState, ILogger logger)
public static Option<IDecoder> ForVideoFormat(FFmpegState ffmpegState, FrameState currentState, ILogger logger)
{
return (currentState.HardwareAccelerationMode, currentState.VideoFormat,
return (ffmpegState.HardwareAccelerationMode, currentState.VideoFormat,
currentState.PixelFormat.Match(pf => pf.Name, () => string.Empty)) switch
{
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc, _) => new DecoderHevcCuvid(),
@ -53,6 +53,7 @@ public static class AvailableDecoders @@ -53,6 +53,7 @@ public static class AvailableDecoders
(_, VideoFormat.Undetermined, _) => new DecoderImplicit(),
(_, VideoFormat.Copy, _) => new DecoderImplicit(),
(_, VideoFormat.GeneratedImage, _) => new DecoderImplicit(),
var (accel, videoFormat, pixelFormat) => LogUnknownDecoder(accel, videoFormat, pixelFormat, logger)
};

5
ErsatzTV.FFmpeg/Decoder/DecoderBase.cs

@ -13,4 +13,9 @@ public abstract class DecoderBase : IDecoder @@ -13,4 +13,9 @@ public abstract class DecoderBase : IDecoder
public virtual FrameState NextState(FrameState currentState) =>
currentState with { FrameDataLocation = OutputFrameDataLocation };
public abstract string Name { get; }
public bool AppliesTo(AudioInputFile audioInputFile) => false;
public bool AppliesTo(VideoInputFile videoInputFile) => true;
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
}

6
ErsatzTV.FFmpeg/Decoder/IDecoder.cs

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
namespace ErsatzTV.FFmpeg.Decoder;
using ErsatzTV.FFmpeg.Option;
public interface IDecoder : IPipelineStep
namespace ErsatzTV.FFmpeg.Decoder;
public interface IDecoder : IInputOption
{
string Name { get; }
}

7
ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs

@ -3,6 +3,7 @@ using ErsatzTV.FFmpeg.Encoder.Qsv; @@ -3,6 +3,7 @@ using ErsatzTV.FFmpeg.Encoder.Qsv;
using ErsatzTV.FFmpeg.Encoder.Vaapi;
using ErsatzTV.FFmpeg.Encoder.VideoToolbox;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Logging;
using LanguageExt;
@ -10,8 +11,8 @@ namespace ErsatzTV.FFmpeg.Encoder; @@ -10,8 +11,8 @@ namespace ErsatzTV.FFmpeg.Encoder;
public static class AvailableEncoders
{
public static Option<IEncoder> ForVideoFormat(FrameState currentState, FrameState desiredState, ILogger logger) =>
(desiredState.HardwareAccelerationMode, desiredState.VideoFormat) switch
public static Option<IEncoder> ForVideoFormat(FFmpegState ffmpegState, FrameState currentState, FrameState desiredState, ILogger logger) =>
(ffmpegState.HardwareAccelerationMode, desiredState.VideoFormat) switch
{
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc) => new EncoderHevcNvenc(),
(HardwareAccelerationMode.Nvenc, VideoFormat.H264) => new EncoderH264Nvenc(),
@ -47,7 +48,7 @@ public static class AvailableEncoders @@ -47,7 +48,7 @@ public static class AvailableEncoders
return Option<IEncoder>.None;
}
public static Option<IEncoder> ForAudioFormat(FrameState desiredState, ILogger logger)
public static Option<IEncoder> ForAudioFormat(AudioState desiredState, ILogger logger)
{
return desiredState.AudioFormat.Match(
audioFormat =>

6
ErsatzTV.FFmpeg/Encoder/EncoderAac.cs

@ -1,11 +1,7 @@ @@ -1,11 +1,7 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Encoder;
namespace ErsatzTV.FFmpeg.Encoder;
public class EncoderAac : EncoderBase
{
public override FrameState NextState(FrameState currentState) => currentState with { AudioFormat = AudioFormat.Aac };
public override string Name => "aac";
public override StreamKind Kind => StreamKind.Audio;

6
ErsatzTV.FFmpeg/Encoder/EncoderAc3.cs

@ -1,11 +1,7 @@ @@ -1,11 +1,7 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Encoder;
namespace ErsatzTV.FFmpeg.Encoder;
public class EncoderAc3 : EncoderBase
{
public override FrameState NextState(FrameState currentState) => currentState with { AudioFormat = AudioFormat.Ac3 };
public override string Name => "ac3";
public override StreamKind Kind => StreamKind.Audio;

2
ErsatzTV.FFmpeg/Encoder/EncoderBase.cs

@ -9,7 +9,7 @@ public abstract class EncoderBase : IEncoder @@ -9,7 +9,7 @@ public abstract class EncoderBase : IEncoder
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> FilterOptions => Array.Empty<string>();
public virtual IList<string> OutputOptions => new List<string> { Kind == StreamKind.Video ? "-c:v" : "-c:a", Name };
public abstract FrameState NextState(FrameState currentState);
public virtual FrameState NextState(FrameState currentState) => currentState;
public abstract string Name { get; }
public abstract StreamKind Kind { get; }

11
ErsatzTV.FFmpeg/Environment/FFReportVariable.cs

@ -1,23 +1,24 @@ @@ -1,23 +1,24 @@
using System.Runtime.InteropServices;
using LanguageExt;
namespace ErsatzTV.FFmpeg.Environment;
public class FFReportVariable : IPipelineStep
{
private readonly string _reportsFolder;
private readonly IList<InputFile> _inputFiles;
private readonly Option<ConcatInputFile> _maybeConcatInputFile;
public FFReportVariable(string reportsFolder, IList<InputFile> inputFiles)
public FFReportVariable(string reportsFolder, Option<ConcatInputFile> maybeConcatInputFile)
{
_reportsFolder = reportsFolder;
_inputFiles = inputFiles;
_maybeConcatInputFile = maybeConcatInputFile;
}
public IList<EnvironmentVariable> EnvironmentVariables
{
get
{
string fileName = _inputFiles.OfType<ConcatInputFile>().Any()
string fileName = _maybeConcatInputFile.IsSome
? Path.Combine(_reportsFolder, "ffmpeg-%t-concat.log")
: Path.Combine(_reportsFolder, "ffmpeg-%t-transcode.log");
@ -42,5 +43,5 @@ public class FFReportVariable : IPipelineStep @@ -42,5 +43,5 @@ public class FFReportVariable : IPipelineStep
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState with { SaveReport = true };
public FrameState NextState(FrameState currentState) => currentState;
}

2
ErsatzTV.FFmpeg/Environment/LibvaDriverNameVariable.cs

@ -18,5 +18,5 @@ public class LibvaDriverNameVariable : IPipelineStep @@ -18,5 +18,5 @@ public class LibvaDriverNameVariable : IPipelineStep
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState with { VaapiDriver = _driverName };
public FrameState NextState(FrameState currentState) => currentState;
}

5
ErsatzTV.FFmpeg/FFmpegPipeline.cs

@ -1,6 +1,3 @@ @@ -1,6 +1,3 @@
namespace ErsatzTV.FFmpeg;
public record FFmpegPipeline(
IList<IPipelineStep> PipelineSteps,
IList<IPipelineFilterStep> VideoFilterSteps,
IList<IPipelineFilterStep> AudioFilterSteps);
public record FFmpegPipeline(IList<IPipelineStep> PipelineSteps);

38
ErsatzTV.FFmpeg/FFmpegState.cs

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
using ErsatzTV.FFmpeg.OutputFormat;
using LanguageExt;
namespace ErsatzTV.FFmpeg;
public record FFmpegState(
bool SaveReport,
HardwareAccelerationMode HardwareAccelerationMode,
Option<string> VaapiDriver,
Option<string> VaapiDevice,
Option<TimeSpan> Start,
Option<TimeSpan> Finish,
bool DoNotMapMetadata,
Option<string> MetadataServiceProvider,
Option<string> MetadataServiceName,
Option<string> MetadataAudioLanguage,
OutputFormatKind OutputFormat,
Option<string> HlsPlaylistPath,
Option<string> HlsSegmentTemplate,
long PtsOffset)
{
public static FFmpegState Concat(bool saveReport, string channelName) =>
new(
saveReport,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
Option<TimeSpan>.None,
Option<TimeSpan>.None,
true, // do not map metadata
"ErsatzTV",
channelName,
Option<string>.None,
OutputFormatKind.MpegTs,
Option<string>.None,
Option<string>.None,
0);
}

2
ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs

@ -20,5 +20,5 @@ public class AudioPadFilter : BaseFilter @@ -20,5 +20,5 @@ public class AudioPadFilter : BaseFilter
}
}
public override FrameState NextState(FrameState currentState) => currentState with { AudioDuration = _wholeDuration };
public override FrameState NextState(FrameState currentState) => currentState;
}

19
ErsatzTV.FFmpeg/Filter/AvailableOverlayFilters.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using ErsatzTV.FFmpeg.Filter.Cuda;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.FFmpeg.Filter;
public static class AvailableOverlayFilters
{
public static IPipelineFilterStep ForAcceleration(
HardwareAccelerationMode accelMode,
WatermarkState watermarkState,
FrameSize resolution) =>
accelMode switch
{
HardwareAccelerationMode.Nvenc => new OverlayCudaFilter(watermarkState, resolution),
// HardwareAccelerationMode.Qsv => new DeinterlaceQsvFilter(),
// HardwareAccelerationMode.Vaapi => new DeinterlaceVaapiFilter(),
_ => new OverlayFilter(watermarkState, resolution)
};
}

2
ErsatzTV.FFmpeg/Filter/BaseFilter.cs

@ -6,7 +6,7 @@ public abstract class BaseFilter : IPipelineFilterStep @@ -6,7 +6,7 @@ public abstract class BaseFilter : IPipelineFilterStep
{
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public virtual IList<string> GlobalOptions => Array.Empty<string>();
public virtual IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public virtual IList<string> FilterOptions => Array.Empty<string>();
public virtual IList<string> OutputOptions => Array.Empty<string>();
public abstract FrameState NextState(FrameState currentState);

148
ErsatzTV.FFmpeg/Filter/ComplexFilter.cs

@ -1,62 +1,135 @@ @@ -1,62 +1,135 @@
using ErsatzTV.FFmpeg.Environment;
using LanguageExt;
namespace ErsatzTV.FFmpeg.Filter;
public class ComplexFilter : IPipelineStep
{
private readonly IList<InputFile> _inputFiles;
private readonly IList<IPipelineFilterStep> _audioFilters;
private readonly IList<IPipelineFilterStep> _videoFilters;
private readonly FFmpegState _ffmpegState;
private readonly Option<VideoInputFile> _maybeVideoInputFile;
private readonly Option<AudioInputFile> _maybeAudioInputFile;
private readonly Option<WatermarkInputFile> _maybeWatermarkInputFile;
private readonly FrameSize _resolution;
public ComplexFilter(IList<InputFile> inputFiles, IList<IPipelineFilterStep> audioFilters, IList<IPipelineFilterStep> videoFilters)
public ComplexFilter(
FFmpegState ffmpegState,
Option<VideoInputFile> maybeVideoInputFile,
Option<AudioInputFile> maybeAudioInputFile,
Option<WatermarkInputFile> maybeWatermarkInputFile,
FrameSize resolution)
{
_inputFiles = inputFiles;
_audioFilters = audioFilters;
_videoFilters = videoFilters;
_ffmpegState = ffmpegState;
_maybeVideoInputFile = maybeVideoInputFile;
_maybeAudioInputFile = maybeAudioInputFile;
_maybeWatermarkInputFile = maybeWatermarkInputFile;
_resolution = resolution;
}
private IList<string> Arguments()
{
var audioLabel = "0:a";
var videoLabel = "0:v";
string watermarkLabel;
var result = new List<string>();
string audioFilterComplex = string.Empty;
string videoFilterComplex = string.Empty;
string watermarkFilterComplex = string.Empty;
string overlayFilterComplex = string.Empty;
for (var i = 0; i < _inputFiles.Count; i++)
var distinctPaths = new List<string>();
foreach ((string path, _) in _maybeVideoInputFile)
{
InputFile file = _inputFiles[i];
for (var j = 0; j < file.Streams.Count; j++)
if (!distinctPaths.Contains(path))
{
MediaStream stream = file.Streams[j];
switch (stream.Kind)
distinctPaths.Add(path);
}
}
foreach ((string path, _) in _maybeAudioInputFile)
{
if (!distinctPaths.Contains(path))
{
distinctPaths.Add(path);
}
}
foreach ((string path, _) in _maybeWatermarkInputFile)
{
if (!distinctPaths.Contains(path))
{
distinctPaths.Add(path);
}
}
foreach (VideoInputFile videoInputFile in _maybeVideoInputFile)
{
int inputIndex = distinctPaths.IndexOf(videoInputFile.Path);
foreach ((int index, _, _) in videoInputFile.Streams)
{
videoLabel = $"{inputIndex}:{index}";
if (videoInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
videoFilterComplex += $"[{inputIndex}:{index}]";
videoFilterComplex += string.Join(
",",
videoInputFile.FilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[v]";
videoFilterComplex += videoLabel;
}
}
}
foreach (AudioInputFile audioInputFile in _maybeAudioInputFile)
{
int inputIndex = distinctPaths.IndexOf(audioInputFile.Path);
foreach ((int index, _, _) in audioInputFile.Streams)
{
audioLabel = $"{inputIndex}:{index}";
if (audioInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
audioFilterComplex += $"[{inputIndex}:{index}]";
audioFilterComplex += string.Join(
",",
audioInputFile.FilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
audioLabel = "[a]";
audioFilterComplex += audioLabel;
}
}
}
foreach (WatermarkInputFile watermarkInputFile in _maybeWatermarkInputFile)
{
int inputIndex = distinctPaths.IndexOf(watermarkInputFile.Path);
foreach ((int index, _, _) in watermarkInputFile.Streams)
{
watermarkLabel = $"{inputIndex}:{index}";
if (watermarkInputFile.FilterSteps.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
watermarkFilterComplex += $"[{inputIndex}:{index}]";
watermarkFilterComplex += string.Join(
",",
watermarkInputFile.FilterSteps.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
watermarkLabel = "[wm]";
watermarkFilterComplex += watermarkLabel;
}
IPipelineFilterStep overlayFilter = AvailableOverlayFilters.ForAcceleration(
_ffmpegState.HardwareAccelerationMode,
watermarkInputFile.DesiredState,
_resolution);
if (overlayFilter.Filter != string.Empty)
{
case StreamKind.Audio:
audioLabel = $"{i}:{stream.Index}";
if (_audioFilters.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
audioFilterComplex += $"[{i}:{stream.Index}]";
audioFilterComplex += string.Join(
",",
_audioFilters.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
audioLabel = "[a]";
audioFilterComplex += audioLabel;
}
break;
case StreamKind.Video:
videoLabel = $"{i}:{stream.Index}";
if (_videoFilters.Any(f => !string.IsNullOrWhiteSpace(f.Filter)))
{
videoFilterComplex += $"[{i}:{stream.Index}]";
videoFilterComplex += string.Join(
",",
_videoFilters.Select(f => f.Filter).Filter(s => !string.IsNullOrWhiteSpace(s)));
videoLabel = "[v]";
videoFilterComplex += videoLabel;
}
break;
string tempVideoLabel = string.IsNullOrWhiteSpace(videoFilterComplex)
? $"[{videoLabel}]"
: videoLabel;
overlayFilterComplex = $"{tempVideoLabel}{watermarkLabel}{overlayFilter.Filter}[vf]";
// change the mapped label
videoLabel = "[vf]";
}
}
}
@ -65,7 +138,8 @@ public class ComplexFilter : IPipelineStep @@ -65,7 +138,8 @@ public class ComplexFilter : IPipelineStep
{
var filterComplex = string.Join(
";",
new[] { audioFilterComplex, videoFilterComplex }.Where(s => !string.IsNullOrWhiteSpace(s)));
new[] { audioFilterComplex, videoFilterComplex, watermarkFilterComplex, overlayFilterComplex }.Where(
s => !string.IsNullOrWhiteSpace(s)));
result.AddRange(new[] { "-filter_complex", filterComplex });
}

14
ErsatzTV.FFmpeg/Filter/Cuda/OverlayCudaFilter.cs

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.FFmpeg.Filter.Cuda;
public class OverlayCudaFilter : OverlayFilter
{
public OverlayCudaFilter(WatermarkState watermarkState, FrameSize resolution) : base(watermarkState, resolution)
{
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter => $"overlay_cuda={Position}";
}

21
ErsatzTV.FFmpeg/Filter/HardwareUploadFilter.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
namespace ErsatzTV.FFmpeg.Filter;
public class HardwareUploadFilter : BaseFilter
{
private readonly FFmpegState _ffmpegState;
public HardwareUploadFilter(FFmpegState ffmpegState)
{
_ffmpegState = ffmpegState;
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter => _ffmpegState.HardwareAccelerationMode switch
{
HardwareAccelerationMode.None => string.Empty,
HardwareAccelerationMode.Nvenc => "hwupload_cuda",
HardwareAccelerationMode.Qsv => "hwupload=extra_hw_frames=64",
_ => "hwupload"
};
}

2
ErsatzTV.FFmpeg/Filter/NormalizeLoudnessFilter.cs

@ -4,5 +4,5 @@ public class NormalizeLoudnessFilter : BaseFilter @@ -4,5 +4,5 @@ public class NormalizeLoudnessFilter : BaseFilter
{
public override string Filter => "loudnorm=I=-16:TP=-1.5:LRA=11";
public override FrameState NextState(FrameState currentState) => currentState with { NormalizeLoudness = true };
public override FrameState NextState(FrameState currentState) => currentState;
}

40
ErsatzTV.FFmpeg/Filter/OverlayFilter.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.FFmpeg.Filter;
public class OverlayFilter : BaseFilter
{
private readonly WatermarkState _watermarkState;
private readonly FrameSize _resolution;
public OverlayFilter(WatermarkState watermarkState, FrameSize resolution)
{
_watermarkState = watermarkState;
_resolution = resolution;
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter => $"overlay={Position}";
protected string Position
{
get
{
double horizontalMargin = Math.Round(_watermarkState.HorizontalMarginPercent / 100.0 * _resolution.Width);
double verticalMargin = Math.Round(_watermarkState.VerticalMarginPercent / 100.0 * _resolution.Height);
return _watermarkState.Location switch
{
WatermarkLocation.BottomLeft => $"x={horizontalMargin}:y=H-h-{verticalMargin}",
WatermarkLocation.TopLeft => $"x={horizontalMargin}:y={verticalMargin}",
WatermarkLocation.TopRight => $"x=W-w-{horizontalMargin}:y={verticalMargin}",
WatermarkLocation.TopMiddle => $"x=(W-w)/2:y={verticalMargin}",
WatermarkLocation.RightMiddle => $"x=W-w-{horizontalMargin}:y=(H-h)/2",
WatermarkLocation.BottomMiddle => $"x=(W-w)/2:y=H-h-{verticalMargin}",
WatermarkLocation.LeftMiddle => $"x={horizontalMargin}:y=(H-h)/2",
_ => $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}"
};
}
}
}

19
ErsatzTV.FFmpeg/Filter/PixelFormatFilter.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using ErsatzTV.FFmpeg.Format;
using static LanguageExt.Prelude;
namespace ErsatzTV.FFmpeg.Filter;
public class PixelFormatFilter : BaseFilter
{
private readonly IPixelFormat _pixelFormat;
public PixelFormatFilter(IPixelFormat pixelFormat)
{
_pixelFormat = pixelFormat;
}
public override FrameState NextState(FrameState currentState) =>
currentState with { PixelFormat = Some(_pixelFormat) };
public override string Filter => $"format={_pixelFormat.FFmpegName}";
}

34
ErsatzTV.FFmpeg/Filter/WatermarkFadeFilter.cs

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
using System.Globalization;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.FFmpeg.Filter;
public class WatermarkFadeFilter : BaseFilter
{
private readonly WatermarkFadePoint _fadePoint;
public WatermarkFadeFilter(WatermarkFadePoint fadePoint)
{
_fadePoint = fadePoint;
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter
{
get
{
var startTime = _fadePoint.Time.TotalSeconds.ToString(NumberFormatInfo.InvariantInfo);
var enableStart = _fadePoint.EnableStart.TotalSeconds.ToString(NumberFormatInfo.InvariantInfo);
var enableFinish = _fadePoint.EnableFinish.TotalSeconds.ToString(NumberFormatInfo.InvariantInfo);
string inOut = _fadePoint switch
{
WatermarkFadeIn => "in",
_ => "out"
};
return $"fade={inOut}:st={startTime}:d=1:alpha=1:enable='between(t,{enableStart},{enableFinish})'";
}
}
}

24
ErsatzTV.FFmpeg/Filter/WatermarkOpacityFilter.cs

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.FFmpeg.Filter;
public class WatermarkOpacityFilter : BaseFilter
{
private readonly WatermarkState _desiredState;
public WatermarkOpacityFilter(WatermarkState desiredState)
{
_desiredState = desiredState;
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter
{
get
{
double opacity = _desiredState.Opacity / 100.0;
return $"colorchannelmixer=aa={opacity:F2}";
}
}
}

37
ErsatzTV.FFmpeg/Filter/WatermarkPixelFormatFilter.cs

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.FFmpeg.Filter;
public class WatermarkPixelFormatFilter : BaseFilter
{
private readonly FFmpegState _ffmpegState;
private readonly WatermarkState _watermarkState;
public WatermarkPixelFormatFilter(FFmpegState ffmpegState, WatermarkState watermarkState)
{
_ffmpegState = ffmpegState;
_watermarkState = watermarkState;
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter
{
get
{
bool hasFadePoints = _watermarkState.MaybeFadePoints.Map(fp => fp.Count).IfNone(0) > 0;
Option<string> maybeFormat = _ffmpegState.HardwareAccelerationMode switch
{
HardwareAccelerationMode.Nvenc => "yuva420p",
_ when _watermarkState.Opacity != 100 || hasFadePoints =>
"yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8",
_ => None
};
return maybeFormat.Match(f => $"format={f}", () => string.Empty);
}
}
}

26
ErsatzTV.FFmpeg/Filter/WatermarkScaleFilter.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.FFmpeg.Filter;
public class WatermarkScaleFilter : BaseFilter
{
private readonly WatermarkState _watermarkState;
private readonly FrameSize _resolution;
public WatermarkScaleFilter(WatermarkState watermarkState, FrameSize resolution)
{
_watermarkState = watermarkState;
_resolution = resolution;
}
public override FrameState NextState(FrameState currentState) => currentState;
public override string Filter
{
get
{
double width = Math.Round(_watermarkState.WidthPercent / 100.0 * _resolution.Width);
return $"scale={width}:-1";
}
}
}

8
ErsatzTV.FFmpeg/Format/ConcatInputFormat.cs

@ -1,8 +1,9 @@ @@ -1,8 +1,9 @@
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Option;
namespace ErsatzTV.FFmpeg.Format;
public class ConcatInputFormat : IPipelineStep
public class ConcatInputFormat : IInputOption
{
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>();
@ -18,4 +19,9 @@ public class ConcatInputFormat : IPipelineStep @@ -18,4 +19,9 @@ public class ConcatInputFormat : IPipelineStep
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState;
public bool AppliesTo(AudioInputFile audioInputFile) => false;
public bool AppliesTo(VideoInputFile videoInputFile) => false;
public bool AppliesTo(ConcatInputFile concatInputFile) => true;
}

1
ErsatzTV.FFmpeg/Format/VideoFormat.cs

@ -15,5 +15,6 @@ public static class VideoFormat @@ -15,5 +15,6 @@ public static class VideoFormat
public const string MpegTs = "mpegts";
public const string Copy = "copy";
public const string GeneratedImage = "generated-image";
public const string Undetermined = "";
}

60
ErsatzTV.FFmpeg/FrameState.cs

@ -1,18 +1,11 @@ @@ -1,18 +1,11 @@
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
using LanguageExt;
namespace ErsatzTV.FFmpeg;
public record FrameState(
bool SaveReport,
HardwareAccelerationMode HardwareAccelerationMode,
Option<string> VaapiDriver,
Option<string> VaapiDevice,
bool Realtime,
bool InfiniteLoop,
Option<TimeSpan> Start,
Option<TimeSpan> Finish,
string VideoFormat,
Option<IPixelFormat> PixelFormat,
FrameSize ScaledSize,
@ -22,55 +15,4 @@ public record FrameState( @@ -22,55 +15,4 @@ public record FrameState(
Option<int> VideoBufferSize,
Option<int> VideoTrackTimeScale,
bool Deinterlaced,
Option<string> AudioFormat,
Option<int> AudioChannels,
Option<int> AudioBitrate,
Option<int> AudioBufferSize,
Option<int> AudioSampleRate,
Option<TimeSpan> AudioDuration,
bool NormalizeLoudness,
bool DoNotMapMetadata,
Option<string> MetadataServiceProvider,
Option<string> MetadataServiceName,
Option<string> MetadataAudioLanguage,
OutputFormatKind OutputFormat,
Option<string> HlsPlaylistPath,
Option<string> HlsSegmentTemplate,
long PtsOffset,
FrameDataLocation FrameDataLocation = FrameDataLocation.Unknown)
{
public static FrameState Concat(bool saveReport, string channelName, FrameSize resolution) =>
new(
saveReport,
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
true, // realtime
true, // infinite loop
Option<TimeSpan>.None,
Option<TimeSpan>.None,
Format.VideoFormat.Undetermined,
Option<IPixelFormat>.None,
resolution,
resolution,
Option<int>.None,
Option<int>.None,
Option<int>.None,
Option<int>.None,
false,
Option<string>.None,
Option<int>.None,
Option<int>.None,
Option<int>.None,
Option<int>.None,
Option<TimeSpan>.None,
false, // normalize loudness
true, // do not map metadata
"ErsatzTV",
channelName,
Option<string>.None,
OutputFormatKind.MpegTs,
Option<string>.None,
Option<string>.None,
0);
}
FrameDataLocation FrameDataLocation = FrameDataLocation.Unknown);

66
ErsatzTV.FFmpeg/InputFile.cs

@ -1,17 +1,63 @@ @@ -1,17 +1,63 @@
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.Option;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
namespace ErsatzTV.FFmpeg;
public record InputFile(string Path, IList<MediaStream> Streams);
public abstract record InputFile(string Path, IList<MediaStream> Streams)
{
public List<IInputOption> InputOptions { get; } = new();
public List<IPipelineFilterStep> FilterSteps { get; } = new();
}
public record ConcatInputFile(string Url, FrameSize Resolution) : InputFile(
Url,
new List<MediaStream>
{
new VideoStream(
0,
string.Empty,
Option<IPixelFormat>.None,
Resolution,
Option<string>.None,
false)
})
{
public void AddOption(IInputOption option)
{
if (option.AppliesTo(this))
{
InputOptions.Add(option);
}
}
}
public record ConcatInputFile(string Url, FrameSize Resolution) : InputFile(Url, new List<MediaStream>
public record AudioInputFile(string Path, IList<AudioStream> AudioStreams, AudioState DesiredState) : InputFile(
Path,
AudioStreams.Cast<MediaStream>().ToList())
{
new VideoStream(
0,
string.Empty,
Option<IPixelFormat>.None,
Resolution,
Option<string>.None,
false)
});
public void AddOption(IInputOption option)
{
if (option.AppliesTo(this))
{
InputOptions.Add(option);
}
}
}
public record VideoInputFile(string Path, IList<VideoStream> VideoStreams) : InputFile(
Path,
VideoStreams.Cast<MediaStream>().ToList())
{
public void AddOption(IInputOption option)
{
if (option.AppliesTo(this))
{
InputOptions.Add(option);
}
}
}
public record WatermarkInputFile
(string Path, IList<VideoStream> VideoStreams, WatermarkState DesiredState) : VideoInputFile(Path, VideoStreams);

5
ErsatzTV.FFmpeg/Option/AudioBitrateOutputOption.cs

@ -14,9 +14,4 @@ public class AudioBitrateOutputOption : OutputOption @@ -14,9 +14,4 @@ public class AudioBitrateOutputOption : OutputOption
"-b:a", $"{_bitrate}k",
"-maxrate:a", $"{_bitrate}k",
};
public override FrameState NextState(FrameState currentState) => currentState with
{
AudioBitrate = _bitrate
};
}

5
ErsatzTV.FFmpeg/Option/AudioBufferSizeOutputOption.cs

@ -13,9 +13,4 @@ public class AudioBufferSizeOutputOption : OutputOption @@ -13,9 +13,4 @@ public class AudioBufferSizeOutputOption : OutputOption
{
"-bufsize:a", $"{_decoderBufferSize}k"
};
public override FrameState NextState(FrameState currentState) => currentState with
{
AudioBufferSize = _decoderBufferSize
};
}

2
ErsatzTV.FFmpeg/Option/AudioChannelsOutputOption.cs

@ -10,6 +10,4 @@ public class AudioChannelsOutputOption : OutputOption @@ -10,6 +10,4 @@ public class AudioChannelsOutputOption : OutputOption
}
public override IList<string> OutputOptions => new List<string> { "-ac", _channels.ToString() };
public override FrameState NextState(FrameState currentState) => currentState with { AudioChannels = _channels };
}

3
ErsatzTV.FFmpeg/Option/AudioSampleRateOutputOption.cs

@ -10,7 +10,4 @@ public class AudioSampleRateOutputOption : OutputOption @@ -10,7 +10,4 @@ public class AudioSampleRateOutputOption : OutputOption
}
public override IList<string> OutputOptions => new List<string> { "-ar", $"{_sampleRate}k" };
public override FrameState NextState(FrameState currentState) =>
currentState with { AudioSampleRate = _sampleRate };
}

21
ErsatzTV.FFmpeg/Option/DoNotIgnoreLoopInputOption.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
using ErsatzTV.FFmpeg.Environment;
namespace ErsatzTV.FFmpeg.Option;
public class DoNotIgnoreLoopInputOption : IInputOption
{
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => new List<string> { "-ignore_loop", "0" };
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState;
public bool AppliesTo(AudioInputFile audioInputFile) => false;
public bool AppliesTo(VideoInputFile videoInputFile) => videoInputFile.VideoStreams.All(s => s.StillImage == false);
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
}

1
ErsatzTV.FFmpeg/Option/HardwareAcceleration/AvailableHardwareAccelerationOptions.cs

@ -15,6 +15,7 @@ public static class AvailableHardwareAccelerationOptions @@ -15,6 +15,7 @@ public static class AvailableHardwareAccelerationOptions
HardwareAccelerationMode.Qsv => new QsvHardwareAccelerationOption(),
HardwareAccelerationMode.Vaapi => GetVaapiAcceleration(vaapiDevice, logger),
HardwareAccelerationMode.VideoToolbox => new VideoToolboxHardwareAccelerationOption(),
HardwareAccelerationMode.None => Option<IPipelineStep>.None,
_ => LogUnknownMode(mode, logger)
};

5
ErsatzTV.FFmpeg/Option/HardwareAcceleration/CudaHardwareAccelerationOption.cs

@ -3,9 +3,4 @@ @@ -3,9 +3,4 @@
public class CudaHardwareAccelerationOption : GlobalOption
{
public override IList<string> GlobalOptions => new List<string> { "-hwaccel", "cuda" };
public override FrameState NextState(FrameState currentState) => currentState with
{
HardwareAccelerationMode = HardwareAccelerationMode.Nvenc
};
}

2
ErsatzTV.FFmpeg/Option/HardwareAcceleration/QsvHardwareAccelerationOption.cs

@ -34,7 +34,7 @@ public class QsvHardwareAccelerationOption : GlobalOption @@ -34,7 +34,7 @@ public class QsvHardwareAccelerationOption : GlobalOption
// qsv encoders want nv12
public override FrameState NextState(FrameState currentState)
{
FrameState result = currentState with { HardwareAccelerationMode = HardwareAccelerationMode.Qsv };
FrameState result = currentState;
foreach (IPixelFormat pixelFormat in currentState.PixelFormat)
{

1
ErsatzTV.FFmpeg/Option/HardwareAcceleration/VaapiHardwareAccelerationOption.cs

@ -14,7 +14,6 @@ public class VaapiHardwareAccelerationOption : GlobalOption @@ -14,7 +14,6 @@ public class VaapiHardwareAccelerationOption : GlobalOption
public override FrameState NextState(FrameState currentState) => currentState with
{
HardwareAccelerationMode = HardwareAccelerationMode.Vaapi,
FrameDataLocation = FrameDataLocation.Hardware
};
}

5
ErsatzTV.FFmpeg/Option/HardwareAcceleration/VideoToolboxHardwareAccelerationOption.cs

@ -3,9 +3,4 @@ @@ -3,9 +3,4 @@
public class VideoToolboxHardwareAccelerationOption : GlobalOption
{
public override IList<string> GlobalOptions => new List<string> { "-hwaccel", "videotoolbox" };
public override FrameState NextState(FrameState currentState) => currentState with
{
HardwareAccelerationMode = HardwareAccelerationMode.VideoToolbox
};
}

8
ErsatzTV.FFmpeg/Option/IInputOption.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.FFmpeg.Option;
public interface IInputOption : IPipelineStep
{
bool AppliesTo(AudioInputFile audioInputFile);
bool AppliesTo(VideoInputFile videoInputFile);
bool AppliesTo(ConcatInputFile concatInputFile);
}

30
ErsatzTV.FFmpeg/Option/InfiniteLoopInputOption.cs

@ -2,41 +2,39 @@ @@ -2,41 +2,39 @@
namespace ErsatzTV.FFmpeg.Option;
public class InfiniteLoopInputOption : IPipelineStep
public class InfiniteLoopInputOption : IInputOption
{
private readonly FrameState _currentState;
private readonly HardwareAccelerationMode _hardwareAccelerationMode;
public InfiniteLoopInputOption(FrameState currentState)
public InfiniteLoopInputOption(HardwareAccelerationMode hardwareAccelerationMode)
{
_currentState = currentState;
_hardwareAccelerationMode = hardwareAccelerationMode;
}
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile)
{
// only loop video media
if (inputFile.Streams.OfType<VideoStream>().Any())
// loop 1 for still images
if (inputFile.Streams.OfType<VideoStream>().Any(s => s.StillImage))
{
// loop 1 for still images
if (inputFile.Streams.OfType<VideoStream>().Any(s => s.StillImage))
{
return new List<string> { "-loop", "1" };
}
// stream_loop for looped video i.e. filler
return new List<string> { "-stream_loop", "-1" };
return new List<string> { "-loop", "1" };
}
return Array.Empty<string>();
// stream_loop for looped video i.e. filler
return new List<string> { "-stream_loop", "-1" };
}
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions =>
_currentState.HardwareAccelerationMode is HardwareAccelerationMode.Qsv or HardwareAccelerationMode.Vaapi
_hardwareAccelerationMode is HardwareAccelerationMode.Qsv or HardwareAccelerationMode.Vaapi
? new List<string> { "-noautoscale" }
: Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState with { Realtime = true };
public bool AppliesTo(AudioInputFile audioInputFile) => false;
public bool AppliesTo(VideoInputFile videoInputFile) => true;
public bool AppliesTo(ConcatInputFile concatInputFile) => true;
}

2
ErsatzTV.FFmpeg/Option/Metadata/DoNotMapMetadataOutputOption.cs

@ -3,6 +3,4 @@ @@ -3,6 +3,4 @@
public class DoNotMapMetadataOutputOption : OutputOption
{
public override IList<string> OutputOptions => new List<string> { "-map_metadata", "-1" };
public override FrameState NextState(FrameState currentState) => currentState with { DoNotMapMetadata = true };
}

5
ErsatzTV.FFmpeg/Option/Metadata/MetadataAudioLanguageOutputOption.cs

@ -11,9 +11,4 @@ public class MetadataAudioLanguageOutputOption : OutputOption @@ -11,9 +11,4 @@ public class MetadataAudioLanguageOutputOption : OutputOption
public override IList<string> OutputOptions => new List<string>
{ "-metadata:s:a:0", $"language={_audioLanguage}" };
public override FrameState NextState(FrameState currentState) => currentState with
{
MetadataAudioLanguage = _audioLanguage
};
}

5
ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceNameOutputOption.cs

@ -11,9 +11,4 @@ public class MetadataServiceNameOutputOption : OutputOption @@ -11,9 +11,4 @@ public class MetadataServiceNameOutputOption : OutputOption
public override IList<string> OutputOptions => new List<string>
{ "-metadata", $"service_name=\"{_serviceName}\"" };
public override FrameState NextState(FrameState currentState) => currentState with
{
MetadataServiceName = _serviceName
};
}

5
ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceProviderOutputOption.cs

@ -11,9 +11,4 @@ public class MetadataServiceProviderOutputOption : OutputOption @@ -11,9 +11,4 @@ public class MetadataServiceProviderOutputOption : OutputOption
public override IList<string> OutputOptions => new List<string>
{ "-metadata", $"service_provider=\"{_serviceProvider}\"" };
public override FrameState NextState(FrameState currentState) => currentState with
{
MetadataServiceProvider = _serviceProvider
};
}

2
ErsatzTV.FFmpeg/Option/OutputTsOffsetOption.cs

@ -19,5 +19,5 @@ public class OutputTsOffsetOption : OutputOption @@ -19,5 +19,5 @@ public class OutputTsOffsetOption : OutputOption
$"{(_ptsOffset / (double)_videoTrackTimeScale).ToString(NumberFormatInfo.InvariantInfo)}"
};
public override FrameState NextState(FrameState currentState) => currentState with { PtsOffset = _ptsOffset };
// public override FrameState NextState(FrameState currentState) => currentState with { PtsOffset = _ptsOffset };
}

22
ErsatzTV.FFmpeg/Option/PixelFormatOutputOption.cs

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
using ErsatzTV.FFmpeg.Format;
using static LanguageExt.Prelude;
namespace ErsatzTV.FFmpeg.Option;
public class PixelFormatOutputOption : OutputOption
{
private readonly IPixelFormat _pixelFormat;
public PixelFormatOutputOption(IPixelFormat pixelFormat)
{
_pixelFormat = pixelFormat;
}
public override IList<string> OutputOptions => new List<string>
{
"-pix_fmt", _pixelFormat.FFmpegName
};
public override FrameState NextState(FrameState currentState) =>
currentState with { PixelFormat = Some(_pixelFormat) };
}

12
ErsatzTV.FFmpeg/Option/RealtimeInputOption.cs

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
namespace ErsatzTV.FFmpeg.Option;
public class RealtimeInputOption : IPipelineStep
public class RealtimeInputOption : IInputOption
{
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
@ -10,9 +10,17 @@ public class RealtimeInputOption : IPipelineStep @@ -10,9 +10,17 @@ public class RealtimeInputOption : IPipelineStep
// so we force a single thread here
// public IList<string> GlobalOptions => new List<string> { "-threads", "1" };
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => new List<string> { "-re" };
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState with { Realtime = true };
public bool AppliesTo(AudioInputFile audioInputFile) => true;
// don't use realtime input for a still image
public bool AppliesTo(VideoInputFile videoInputFile) => videoInputFile.VideoStreams.All(s => !s.StillImage);
public bool AppliesTo(ConcatInputFile concatInputFile) => true;
}

18
ErsatzTV.FFmpeg/Option/StreamSeekInputOption.cs

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
namespace ErsatzTV.FFmpeg.Option;
public class StreamSeekInputOption : IPipelineStep
public class StreamSeekInputOption : IInputOption
{
private readonly TimeSpan _start;
@ -13,14 +13,16 @@ public class StreamSeekInputOption : IPipelineStep @@ -13,14 +13,16 @@ public class StreamSeekInputOption : IPipelineStep
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => new List<string> { "-ss", $"{_start:c}" };
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState;
public bool AppliesTo(AudioInputFile audioInputFile) => true;
// don't seek into a still image
public IList<string> InputOptions(InputFile inputFile) =>
_start == TimeSpan.Zero || inputFile.Streams.OfType<VideoStream>().Any(s => s.StillImage)
? Array.Empty<string>()
: new List<string> { "-ss", $"{_start:c}" };
public bool AppliesTo(VideoInputFile videoInputFile) => videoInputFile.VideoStreams.All(s => !s.StillImage);
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => Array.Empty<string>();
public FrameState NextState(FrameState currentState) => currentState with { Start = _start };
// never seek when concatenating
public bool AppliesTo(ConcatInputFile concatInputFile) => false;
}

3
ErsatzTV.FFmpeg/Option/TimeLimitOutputOption.cs

@ -11,11 +11,10 @@ public class TimeLimitOutputOption : IPipelineStep @@ -11,11 +11,10 @@ public class TimeLimitOutputOption : IPipelineStep
_finish = finish;
}
public FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Unknown;
public IList<EnvironmentVariable> EnvironmentVariables => Array.Empty<EnvironmentVariable>();
public IList<string> GlobalOptions => Array.Empty<string>();
public IList<string> InputOptions(InputFile inputFile) => Array.Empty<string>();
public IList<string> FilterOptions => Array.Empty<string>();
public IList<string> OutputOptions => new List<string> { "-t", $"{_finish:c}" };
public FrameState NextState(FrameState currentState) => currentState with { Finish = _finish };
public FrameState NextState(FrameState currentState) => currentState;
}

9
ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs

@ -52,6 +52,8 @@ public class OutputFormatHls : IPipelineStep @@ -52,6 +52,8 @@ public class OutputFormatHls : IPipelineStep
}
}
public FrameState NextState(FrameState currentState) => currentState;
private int GetFrameRateFromMedia()
{
var frameRate = 24;
@ -76,11 +78,4 @@ public class OutputFormatHls : IPipelineStep @@ -76,11 +78,4 @@ public class OutputFormatHls : IPipelineStep
return frameRate;
}
public FrameState NextState(FrameState currentState) => currentState with
{
OutputFormat = OutputFormatKind.Hls,
HlsPlaylistPath = _playlistPath,
HlsSegmentTemplate = _segmentTemplate
};
}

478
ErsatzTV.FFmpeg/PipelineBuilder.cs

@ -8,21 +8,28 @@ using ErsatzTV.FFmpeg.Option.HardwareAcceleration; @@ -8,21 +8,28 @@ using ErsatzTV.FFmpeg.Option.HardwareAcceleration;
using ErsatzTV.FFmpeg.Option.Metadata;
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.Protocol;
using ErsatzTV.FFmpeg.State;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.FFmpeg;
public class PipelineBuilder
{
private readonly List<IPipelineStep> _pipelineSteps;
private readonly List<IPipelineFilterStep> _audioFilterSteps;
private readonly List<IPipelineFilterStep> _videoFilterSteps;
private readonly IList<InputFile> _inputFiles;
private readonly Option<VideoInputFile> _videoInputFile;
private readonly Option<AudioInputFile> _audioInputFile;
private readonly Option<WatermarkInputFile> _watermarkInputFile;
private readonly string _reportsFolder;
private readonly ILogger _logger;
public PipelineBuilder(IList<InputFile> inputFiles, string reportsFolder, ILogger logger)
public PipelineBuilder(
Option<VideoInputFile> videoInputFile,
Option<AudioInputFile> audioInputFile,
Option<WatermarkInputFile> watermarkInputFile,
string reportsFolder,
ILogger logger)
{
_pipelineSteps = new List<IPipelineStep>
{
@ -37,20 +44,51 @@ public class PipelineBuilder @@ -37,20 +44,51 @@ public class PipelineBuilder
new ClosedGopOutputOption(),
};
_audioFilterSteps = new List<IPipelineFilterStep>();
_videoFilterSteps = new List<IPipelineFilterStep>();
_inputFiles = inputFiles;
_videoInputFile = videoInputFile;
_audioInputFile = audioInputFile;
_watermarkInputFile = watermarkInputFile;
_reportsFolder = reportsFolder;
_logger = logger;
}
public FFmpegPipeline Build(FrameState desiredState)
public FFmpegPipeline Concat(ConcatInputFile concatInputFile, FFmpegState ffmpegState)
{
var allVideoStreams = _inputFiles.SelectMany(f => f.Streams)
.Filter(s => s.Kind == StreamKind.Video)
.OfType<VideoStream>()
.ToList();
concatInputFile.AddOption(new ConcatInputFormat());
concatInputFile.AddOption(new RealtimeInputOption());
concatInputFile.AddOption(new InfiniteLoopInputOption(HardwareAccelerationMode.None));
_pipelineSteps.Add(new NoSceneDetectOutputOption(0));
_pipelineSteps.Add(new EncoderCopyAll());
if (ffmpegState.DoNotMapMetadata)
{
_pipelineSteps.Add(new DoNotMapMetadataOutputOption());
}
foreach (string desiredServiceProvider in ffmpegState.MetadataServiceProvider)
{
_pipelineSteps.Add(new MetadataServiceProviderOutputOption(desiredServiceProvider));
}
foreach (string desiredServiceName in ffmpegState.MetadataServiceName)
{
_pipelineSteps.Add(new MetadataServiceNameOutputOption(desiredServiceName));
}
_pipelineSteps.Add(new OutputFormatMpegTs());
_pipelineSteps.Add(new PipeProtocol());
if (ffmpegState.SaveReport)
{
_pipelineSteps.Add(new FFReportVariable(_reportsFolder, concatInputFile));
}
return new FFmpegPipeline(_pipelineSteps);
}
public FFmpegPipeline Build(FFmpegState ffmpegState, FrameState desiredState)
{
var allVideoStreams = _videoInputFile.SelectMany(f => f.VideoStreams).ToList();
// -sc_threshold 0 is unsupported with mpeg2video
_pipelineSteps.Add(
@ -58,14 +96,24 @@ public class PipelineBuilder @@ -58,14 +96,24 @@ public class PipelineBuilder
? new NoSceneDetectOutputOption(0)
: new NoSceneDetectOutputOption(1_000_000_000));
var allAudioStreams = _inputFiles.SelectMany(f => f.Streams)
.Filter(s => s.Kind == StreamKind.Audio)
.OfType<AudioStream>()
.ToList();
if (ffmpegState.SaveReport)
{
_pipelineSteps.Add(new FFReportVariable(_reportsFolder, None));
}
VideoStream videoStream = allVideoStreams.Head();
Option<AudioStream> audioStream = allAudioStreams.HeadOrNone();
if (videoStream != null)
foreach (TimeSpan desiredStart in ffmpegState.Start.Filter(s => s > TimeSpan.Zero))
{
var option = new StreamSeekInputOption(desiredStart);
_audioInputFile.Iter(f => f.AddOption(option));
_videoInputFile.Iter(f => f.AddOption(option));
}
foreach (TimeSpan desiredFinish in ffmpegState.Finish)
{
_pipelineSteps.Add(new TimeLimitOutputOption(desiredFinish));
}
foreach (VideoStream videoStream in allVideoStreams)
{
Option<int> initialFrameRate = Option<int>.None;
foreach (string frameRateString in videoStream.FrameRate)
@ -77,14 +125,8 @@ public class PipelineBuilder @@ -77,14 +125,8 @@ public class PipelineBuilder
}
var currentState = new FrameState(
false, // save report
HardwareAccelerationMode.None,
Option<string>.None,
Option<string>.None,
false, // realtime
false, // infinite loop
Option<TimeSpan>.None,
Option<TimeSpan>.None,
videoStream.Codec,
videoStream.PixelFormat,
videoStream.FrameSize,
@ -93,52 +135,8 @@ public class PipelineBuilder @@ -93,52 +135,8 @@ public class PipelineBuilder
Option<int>.None,
Option<int>.None,
Option<int>.None,
false, // deinterlace
audioStream.Map(a => a.Codec),
audioStream.Map(a => a.Channels),
Option<int>.None,
Option<int>.None,
Option<int>.None,
Option<TimeSpan>.None,
false,
false,
Option<string>.None,
Option<string>.None,
Option<string>.None,
OutputFormatKind.None,
Option<string>.None,
Option<string>.None,
0);
if (desiredState.SaveReport && !currentState.SaveReport)
{
IPipelineStep step = new FFReportVariable(_reportsFolder, _inputFiles);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
foreach (TimeSpan desiredStart in desiredState.Start)
{
if (currentState.Start != desiredStart)
{
// _logger.LogInformation("Setting stream seek: {DesiredStart}", desiredStart);
IPipelineStep step = new StreamSeekInputOption(desiredStart);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
}
foreach (TimeSpan desiredFinish in desiredState.Finish)
{
if (currentState.Finish != desiredFinish)
{
// _logger.LogInformation("Setting time limit: {DesiredFinish}", desiredFinish);
IPipelineStep step = new TimeLimitOutputOption(desiredFinish);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
}
false); // deinterlace
IEncoder encoder;
if (IsDesiredVideoState(currentState, desiredState))
@ -148,79 +146,72 @@ public class PipelineBuilder @@ -148,79 +146,72 @@ public class PipelineBuilder
}
else
{
if (currentState.HardwareAccelerationMode != desiredState.HardwareAccelerationMode)
{
Option<IPipelineStep> maybeAccel = AvailableHardwareAccelerationOptions.ForMode(
desiredState.HardwareAccelerationMode,
desiredState.VaapiDevice,
_logger);
Option<IPipelineStep> maybeAccel = AvailableHardwareAccelerationOptions.ForMode(
ffmpegState.HardwareAccelerationMode,
ffmpegState.VaapiDevice,
_logger);
if (maybeAccel.IsNone)
{
desiredState = desiredState with
{
// disable hw accel if we don't match anything
HardwareAccelerationMode = HardwareAccelerationMode.None
};
}
foreach (IPipelineStep accel in maybeAccel)
if (maybeAccel.IsNone)
{
ffmpegState = ffmpegState with
{
currentState = accel.NextState(currentState);
_pipelineSteps.Add(accel);
}
// disable hw accel if we don't match anything
HardwareAccelerationMode = HardwareAccelerationMode.None
};
}
foreach (string desiredVaapiDriver in desiredState.VaapiDriver)
foreach (IPipelineStep accel in maybeAccel)
{
if (currentState.VaapiDriver != desiredVaapiDriver)
{
IPipelineStep step = new LibvaDriverNameVariable(desiredVaapiDriver);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
currentState = accel.NextState(currentState);
_pipelineSteps.Add(accel);
}
foreach (IDecoder decoder in AvailableDecoders.ForVideoFormat(currentState, desiredState, _logger))
// nvenc requires yuv420p background with yuva420p overlay
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.Nvenc &&
_watermarkInputFile.IsSome)
{
currentState = decoder.NextState(currentState);
_pipelineSteps.Add(decoder);
desiredState = desiredState with { PixelFormat = new PixelFormatYuv420P() };
}
if (_inputFiles.OfType<ConcatInputFile>().Any())
foreach (string desiredVaapiDriver in ffmpegState.VaapiDriver)
{
IPipelineStep concatInputFormat = new ConcatInputFormat();
currentState = concatInputFormat.NextState(currentState);
_pipelineSteps.Add(concatInputFormat);
IPipelineStep step = new LibvaDriverNameVariable(desiredVaapiDriver);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
IPipelineStep copyCodec = new EncoderCopyAll();
currentState = copyCodec.NextState(currentState);
_pipelineSteps.Add(copyCodec);
foreach (IDecoder decoder in AvailableDecoders.ForVideoFormat(ffmpegState, currentState, _logger))
{
foreach (VideoInputFile videoInputFile in _videoInputFile)
{
videoInputFile.AddOption(decoder);
currentState = decoder.NextState(currentState);
}
}
}
if (videoStream.StillImage)
{
_pipelineSteps.Add(new InfiniteLoopInputOption(currentState));
var option = new InfiniteLoopInputOption(ffmpegState.HardwareAccelerationMode);
_videoInputFile.Iter(f => f.AddOption(option));
}
// TODO: while?
if (!IsDesiredVideoState(currentState, desiredState))
{
if (!currentState.Realtime && desiredState.Realtime)
if (desiredState.Realtime)
{
IPipelineStep step = new RealtimeInputOption();
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
var option = new RealtimeInputOption();
_audioInputFile.Iter(f => f.AddOption(option));
_videoInputFile.Iter(f => f.AddOption(option));
}
if (!currentState.InfiniteLoop && desiredState.InfiniteLoop)
if (desiredState.InfiniteLoop)
{
IPipelineStep step = new InfiniteLoopInputOption(currentState);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
var option = new InfiniteLoopInputOption(ffmpegState.HardwareAccelerationMode);
_audioInputFile.Iter(f => f.AddOption(option));
_videoInputFile.Iter(f => f.AddOption(option));
}
foreach (int desiredFrameRate in desiredState.FrameRate)
{
if (currentState.FrameRate != desiredFrameRate)
@ -264,15 +255,14 @@ public class PipelineBuilder @@ -264,15 +255,14 @@ public class PipelineBuilder
if (desiredState.Deinterlaced && !currentState.Deinterlaced)
{
IPipelineFilterStep step = AvailableDeinterlaceFilters.ForAcceleration(
currentState.HardwareAccelerationMode,
ffmpegState.HardwareAccelerationMode,
currentState);
currentState = step.NextState(currentState);
_videoFilterSteps.Add(step);
_videoInputFile.Iter(f => f.FilterSteps.Add(step));
}
// TODO: this is a software-only flow, will need to be different for hardware accel
if (currentState.HardwareAccelerationMode == HardwareAccelerationMode.None)
if (ffmpegState.HardwareAccelerationMode == HardwareAccelerationMode.None)
{
if (currentState.ScaledSize != desiredState.ScaledSize ||
currentState.PaddedSize != desiredState.PaddedSize)
@ -282,82 +272,126 @@ public class PipelineBuilder @@ -282,82 +272,126 @@ public class PipelineBuilder
desiredState.ScaledSize,
desiredState.PaddedSize);
currentState = scaleStep.NextState(currentState);
_videoFilterSteps.Add(scaleStep);
_videoInputFile.Iter(f => f.FilterSteps.Add(scaleStep));
// TODO: padding might not be needed, can we optimize this out?
IPipelineFilterStep padStep = new PadFilter(currentState, desiredState.PaddedSize);
currentState = padStep.NextState(currentState);
_videoFilterSteps.Add(padStep);
_videoInputFile.Iter(f => f.FilterSteps.Add(padStep));
IPipelineFilterStep sarStep = new SetSarFilter();
currentState = sarStep.NextState(currentState);
_videoFilterSteps.Add(sarStep);
_videoInputFile.Iter(f => f.FilterSteps.Add(sarStep));
}
}
else if (currentState.ScaledSize != desiredState.ScaledSize)
{
IPipelineFilterStep scaleFilter = AvailableScaleFilters.ForAcceleration(
currentState.HardwareAccelerationMode,
ffmpegState.HardwareAccelerationMode,
currentState,
desiredState.ScaledSize,
desiredState.PaddedSize);
currentState = scaleFilter.NextState(currentState);
_videoFilterSteps.Add(scaleFilter);
_videoInputFile.Iter(f => f.FilterSteps.Add(scaleFilter));
// TODO: padding might not be needed, can we optimize this out?
if (currentState.PaddedSize != desiredState.PaddedSize)
{
IPipelineFilterStep padStep = new PadFilter(currentState, desiredState.PaddedSize);
currentState = padStep.NextState(currentState);
_videoFilterSteps.Add(padStep);
_videoInputFile.Iter(f => f.FilterSteps.Add(padStep));
}
IPipelineFilterStep sarStep = new SetSarFilter();
currentState = sarStep.NextState(currentState);
_videoFilterSteps.Add(sarStep);
_videoInputFile.Iter(f => f.FilterSteps.Add(sarStep));
}
else if (currentState.PaddedSize != desiredState.PaddedSize)
{
IPipelineFilterStep scaleFilter = AvailableScaleFilters.ForAcceleration(
currentState.HardwareAccelerationMode,
ffmpegState.HardwareAccelerationMode,
currentState,
desiredState.ScaledSize,
desiredState.PaddedSize);
currentState = scaleFilter.NextState(currentState);
_videoFilterSteps.Add(scaleFilter);
_videoInputFile.Iter(f => f.FilterSteps.Add(scaleFilter));
if (currentState.PaddedSize != desiredState.PaddedSize)
{
IPipelineFilterStep padStep = new PadFilter(currentState, desiredState.PaddedSize);
currentState = padStep.NextState(currentState);
_videoFilterSteps.Add(padStep);
_videoInputFile.Iter(f => f.FilterSteps.Add(padStep));
}
IPipelineFilterStep sarStep = new SetSarFilter();
currentState = sarStep.NextState(currentState);
_videoFilterSteps.Add(sarStep);
_videoInputFile.Iter(f => f.FilterSteps.Add(sarStep));
}
else if (_watermarkInputFile.IsSome && currentState.PixelFormat != desiredState.PixelFormat)
{
// this should only happen with nvenc?
// use scale filter to fix pixel format
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
{
if (currentState.FrameDataLocation == FrameDataLocation.Software)
{
IPipelineFilterStep formatFilter = new PixelFormatFilter(pixelFormat);
currentState = formatFilter.NextState(currentState);
_videoInputFile.Iter(f => f.FilterSteps.Add(formatFilter));
_videoInputFile.Iter(f => f.FilterSteps.Add(new HardwareUploadFilter(ffmpegState)));
}
else
{
// the filter re-applies the current pixel format, so we have to set it first
currentState = currentState with { PixelFormat = desiredState.PixelFormat };
IPipelineFilterStep scaleFilter = AvailableScaleFilters.ForAcceleration(
ffmpegState.HardwareAccelerationMode,
currentState,
desiredState.ScaledSize,
desiredState.PaddedSize);
currentState = scaleFilter.NextState(currentState);
_videoInputFile.Iter(f => f.FilterSteps.Add(scaleFilter));
}
}
}
if (currentState.PtsOffset != desiredState.PtsOffset)
if (ffmpegState.PtsOffset > 0)
{
foreach (int videoTrackTimeScale in desiredState.VideoTrackTimeScale)
{
IPipelineStep step = new OutputTsOffsetOption(
desiredState.PtsOffset,
ffmpegState.PtsOffset,
videoTrackTimeScale);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
}
foreach (IPixelFormat desiredPixelFormat in desiredState.PixelFormat)
{
if (currentState.PixelFormat.Map(pf => pf.FFmpegName) != desiredPixelFormat.FFmpegName)
{
IPipelineStep step = new PixelFormatOutputOption(desiredPixelFormat);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
}
// after everything else is done, apply the encoder
if (!_pipelineSteps.OfType<IEncoder>().Any())
{
foreach (IEncoder e in AvailableEncoders.ForVideoFormat(currentState, desiredState, _logger))
foreach (IEncoder e in AvailableEncoders.ForVideoFormat(
ffmpegState,
currentState,
desiredState,
_logger))
{
encoder = e;
_pipelineSteps.Add(encoder);
_videoFilterSteps.Add(encoder);
_videoInputFile.Iter(f => f.FilterSteps.Add(encoder));
currentState = encoder.NextState(currentState);
}
}
@ -366,129 +400,114 @@ public class PipelineBuilder @@ -366,129 +400,114 @@ public class PipelineBuilder
// TODO: if all video filters are software, use software pixel format for hwaccel output
// might be able to skip scale_cuda=format=whatever,hwdownload,format=whatever
if (audioStream.IsSome && IsDesiredAudioState(currentState, desiredState))
{
_pipelineSteps.Add(new EncoderCopyAudio());
}
else
foreach (AudioInputFile audioInputFile in _audioInputFile)
{
// always need to specify audio codec so ffmpeg doesn't default to a codec we don't want
foreach (IEncoder step in AvailableEncoders.ForAudioFormat(desiredState, _logger))
foreach (IEncoder step in AvailableEncoders.ForAudioFormat(audioInputFile.DesiredState, _logger))
{
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
}
// TODO: while?
if (!IsDesiredAudioState(currentState, desiredState))
{
foreach (int desiredAudioChannels in desiredState.AudioChannels)
foreach (int desiredAudioChannels in audioInputFile.DesiredState.AudioChannels)
{
if (currentState.AudioChannels != desiredAudioChannels)
{
var step = new AudioChannelsOutputOption(desiredAudioChannels);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
_pipelineSteps.Add(new AudioChannelsOutputOption(desiredAudioChannels));
}
foreach (int desiredBitrate in desiredState.AudioBitrate)
foreach (int desiredBitrate in audioInputFile.DesiredState.AudioBitrate)
{
if (currentState.AudioBitrate != desiredBitrate)
{
IPipelineStep step = new AudioBitrateOutputOption(desiredBitrate);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
_pipelineSteps.Add(new AudioBitrateOutputOption(desiredBitrate));
}
foreach (int desiredBufferSize in desiredState.AudioBufferSize)
foreach (int desiredBufferSize in audioInputFile.DesiredState.AudioBufferSize)
{
if (currentState.AudioBufferSize != desiredBufferSize)
{
IPipelineStep step = new AudioBufferSizeOutputOption(desiredBufferSize);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
_pipelineSteps.Add(new AudioBufferSizeOutputOption(desiredBufferSize));
}
foreach (int desiredSampleRate in desiredState.AudioSampleRate)
foreach (int desiredSampleRate in audioInputFile.DesiredState.AudioSampleRate)
{
if (currentState.AudioSampleRate != desiredSampleRate)
{
IPipelineStep step = new AudioSampleRateOutputOption(desiredSampleRate);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
_pipelineSteps.Add(new AudioSampleRateOutputOption(desiredSampleRate));
}
if (desiredState.NormalizeLoudness && !currentState.NormalizeLoudness)
if (audioInputFile.DesiredState.NormalizeLoudness)
{
IPipelineFilterStep step = new NormalizeLoudnessFilter();
currentState = step.NextState(currentState);
_audioFilterSteps.Add(step);
_audioInputFile.Iter(f => f.FilterSteps.Add(new NormalizeLoudnessFilter()));
}
foreach (TimeSpan desiredDuration in desiredState.AudioDuration)
foreach (TimeSpan desiredDuration in audioInputFile.DesiredState.AudioDuration)
{
if (currentState.AudioDuration != desiredDuration)
_audioInputFile.Iter(f => f.FilterSteps.Add(new AudioPadFilter(desiredDuration)));
}
}
foreach (WatermarkInputFile watermarkInputFile in _watermarkInputFile)
{
watermarkInputFile.FilterSteps.Add(
new WatermarkPixelFormatFilter(ffmpegState, watermarkInputFile.DesiredState));
foreach (VideoStream watermarkStream in watermarkInputFile.VideoStreams)
{
if (watermarkStream.StillImage == false)
{
IPipelineFilterStep step = new AudioPadFilter(desiredDuration);
currentState = step.NextState(currentState);
_audioFilterSteps.Add(step);
watermarkInputFile.AddOption(new DoNotIgnoreLoopInputOption());
}
else if (watermarkInputFile.DesiredState.MaybeFadePoints.Map(fp => fp.Count > 0).IfNone(false))
{
// looping is required to fade a static image in and out
watermarkInputFile.AddOption(new InfiniteLoopInputOption(ffmpegState.HardwareAccelerationMode));
}
}
if (watermarkInputFile.DesiredState.Size == WatermarkSize.Scaled)
{
watermarkInputFile.FilterSteps.Add(
new WatermarkScaleFilter(watermarkInputFile.DesiredState, currentState.PaddedSize));
}
if (watermarkInputFile.DesiredState.Opacity != 100)
{
watermarkInputFile.FilterSteps.Add(new WatermarkOpacityFilter(watermarkInputFile.DesiredState));
}
foreach (List<WatermarkFadePoint> fadePoints in watermarkInputFile.DesiredState.MaybeFadePoints)
{
watermarkInputFile.FilterSteps.AddRange(fadePoints.Map(fp => new WatermarkFadeFilter(fp)));
}
watermarkInputFile.FilterSteps.Add(new HardwareUploadFilter(ffmpegState));
}
if (desiredState.DoNotMapMetadata && !currentState.DoNotMapMetadata)
if (ffmpegState.DoNotMapMetadata)
{
IPipelineStep step = new DoNotMapMetadataOutputOption();
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
_pipelineSteps.Add(new DoNotMapMetadataOutputOption());
}
foreach (string desiredServiceProvider in desiredState.MetadataServiceProvider)
foreach (string desiredServiceProvider in ffmpegState.MetadataServiceProvider)
{
if (currentState.MetadataServiceProvider != desiredServiceProvider)
{
IPipelineStep step = new MetadataServiceProviderOutputOption(desiredServiceProvider);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
_pipelineSteps.Add(new MetadataServiceProviderOutputOption(desiredServiceProvider));
}
foreach (string desiredServiceName in desiredState.MetadataServiceName)
foreach (string desiredServiceName in ffmpegState.MetadataServiceName)
{
if (currentState.MetadataServiceName != desiredServiceName)
{
IPipelineStep step = new MetadataServiceNameOutputOption(desiredServiceName);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
_pipelineSteps.Add(new MetadataServiceNameOutputOption(desiredServiceName));
}
foreach (string desiredAudioLanguage in desiredState.MetadataAudioLanguage)
foreach (string desiredAudioLanguage in ffmpegState.MetadataAudioLanguage)
{
if (currentState.MetadataAudioLanguage != desiredAudioLanguage)
{
IPipelineStep step = new MetadataAudioLanguageOutputOption(desiredAudioLanguage);
currentState = step.NextState(currentState);
_pipelineSteps.Add(step);
}
_pipelineSteps.Add(new MetadataAudioLanguageOutputOption(desiredAudioLanguage));
}
switch (desiredState.OutputFormat)
switch (ffmpegState.OutputFormat)
{
case OutputFormatKind.MpegTs:
_pipelineSteps.Add(new OutputFormatMpegTs());
_pipelineSteps.Add(new PipeProtocol());
currentState = currentState with { OutputFormat = OutputFormatKind.MpegTs };
// currentState = currentState with { OutputFormat = OutputFormatKind.MpegTs };
break;
case OutputFormatKind.Hls:
foreach (string playlistPath in desiredState.HlsPlaylistPath)
foreach (string playlistPath in ffmpegState.HlsPlaylistPath)
{
foreach (string segmentTemplate in desiredState.HlsSegmentTemplate)
foreach (string segmentTemplate in ffmpegState.HlsSegmentTemplate)
{
var step = new OutputFormatHls(
desiredState,
@ -503,20 +522,22 @@ public class PipelineBuilder @@ -503,20 +522,22 @@ public class PipelineBuilder
break;
}
// add a complex filter unless we are concatenating
if (!_inputFiles.OfType<ConcatInputFile>().Any())
{
_pipelineSteps.Add(new ComplexFilter(_inputFiles, _audioFilterSteps, _videoFilterSteps));
}
var complexFilter = new ComplexFilter(
ffmpegState,
_videoInputFile,
_audioInputFile,
_watermarkInputFile,
currentState.PaddedSize);
_pipelineSteps.Add(complexFilter);
}
return new FFmpegPipeline(_pipelineSteps, _videoFilterSteps, _audioFilterSteps);
return new FFmpegPipeline(_pipelineSteps);
}
private static bool IsDesiredVideoState(FrameState currentState, FrameState desiredState)
{
return currentState.HardwareAccelerationMode == desiredState.HardwareAccelerationMode &&
currentState.VideoFormat == desiredState.VideoFormat &&
return currentState.VideoFormat == desiredState.VideoFormat &&
currentState.PixelFormat.Match(pf => pf.Name, () => string.Empty) ==
desiredState.PixelFormat.Match(pf => pf.Name, string.Empty) &&
(desiredState.VideoBitrate.IsNone || currentState.VideoBitrate == desiredState.VideoBitrate) &&
@ -528,15 +549,4 @@ public class PipelineBuilder @@ -528,15 +549,4 @@ public class PipelineBuilder
currentState.PaddedSize == desiredState.PaddedSize &&
(desiredState.FrameRate.IsNone || currentState.FrameRate == desiredState.FrameRate);
}
private static bool IsDesiredAudioState(FrameState currentState, FrameState desiredState)
{
return currentState.AudioFormat == desiredState.AudioFormat &&
currentState.AudioChannels == desiredState.AudioChannels &&
(desiredState.AudioBitrate.IsNone || currentState.AudioBitrate == desiredState.AudioBitrate) &&
(desiredState.AudioBufferSize.IsNone || currentState.AudioBufferSize == desiredState.AudioBufferSize) &&
(desiredState.AudioSampleRate.IsNone || currentState.AudioSampleRate == desiredState.AudioSampleRate) &&
(desiredState.AudioDuration.IsNone || currentState.AudioDuration == desiredState.AudioDuration) &&
currentState.NormalizeLoudness == desiredState.NormalizeLoudness;
}
}

12
ErsatzTV.FFmpeg/State/AudioState.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using LanguageExt;
namespace ErsatzTV.FFmpeg.State;
public record AudioState(
Option<string> AudioFormat,
Option<int> AudioChannels,
Option<int> AudioBitrate,
Option<int> AudioBufferSize,
Option<int> AudioSampleRate,
Option<TimeSpan> AudioDuration,
bool NormalizeLoudness);

42
ErsatzTV.FFmpeg/State/WatermarkState.cs

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
using LanguageExt;
namespace ErsatzTV.FFmpeg.State;
public record WatermarkState(
Option<List<WatermarkFadePoint>> MaybeFadePoints,
WatermarkLocation Location,
WatermarkSize Size,
int WidthPercent,
int HorizontalMarginPercent,
int VerticalMarginPercent,
int Opacity);
public record WatermarkFadePoint(TimeSpan Time, TimeSpan EnableStart, TimeSpan EnableFinish);
public record WatermarkFadeIn(TimeSpan Time, TimeSpan EnableStart, TimeSpan EnableFinish) : WatermarkFadePoint(
Time,
EnableStart,
EnableFinish);
public record WatermarkFadeOut(TimeSpan Time, TimeSpan EnableStart, TimeSpan EnableFinish) : WatermarkFadePoint(
Time,
EnableStart,
EnableFinish);
public enum WatermarkLocation
{
BottomRight = 0,
BottomLeft = 1,
TopRight = 2,
TopLeft = 3,
TopMiddle = 4,
RightMiddle = 5,
BottomMiddle = 6,
LeftMiddle = 7
}
public enum WatermarkSize
{
Scaled = 0,
ActualSize = 1
}

27
ErsatzTV/Pages/WatermarkEditor.razor

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
@using ErsatzTV.Application.Images.Commands
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Queries
@using ErsatzTV.FFmpeg.State
@inject NavigationManager _navigationManager
@inject ILogger<WatermarkEditor> _logger
@inject ISnackbar _snackbar
@ -55,22 +56,22 @@ @@ -55,22 +56,22 @@
<MudSelect Class="mt-3" Label="Location" @bind-Value="_model.Location"
For="@(() => _model.Location)"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)">
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomRight)">Bottom Right</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomMiddle)">Bottom Middle</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomLeft)">Bottom Left</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.LeftMiddle)">Left Middle</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopLeft)">Top Left</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopMiddle)">Top Middle</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopRight)">Top Right</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.RightMiddle)">Right Middle</MudSelectItem>
<MudSelectItem Value="@(WatermarkLocation.BottomRight)">Bottom Right</MudSelectItem>
<MudSelectItem Value="@(WatermarkLocation.BottomMiddle)">Bottom Middle</MudSelectItem>
<MudSelectItem Value="@(WatermarkLocation.BottomLeft)">Bottom Left</MudSelectItem>
<MudSelectItem Value="@(WatermarkLocation.LeftMiddle)">Left Middle</MudSelectItem>
<MudSelectItem Value="@(WatermarkLocation.TopLeft)">Top Left</MudSelectItem>
<MudSelectItem Value="@(WatermarkLocation.TopMiddle)">Top Middle</MudSelectItem>
<MudSelectItem Value="@(WatermarkLocation.TopRight)">Top Right</MudSelectItem>
<MudSelectItem Value="@(WatermarkLocation.RightMiddle)">Right Middle</MudSelectItem>
</MudSelect>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
<MudSelect Label="Size" @bind-Value="_model.Size"
For="@(() => _model.Size)"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)">
<MudSelectItem Value="@(ChannelWatermarkSize.Scaled)">Scaled</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkSize.ActualSize)">Actual Size</MudSelectItem>
<MudSelectItem Value="@(WatermarkSize.Scaled)">Scaled</MudSelectItem>
<MudSelectItem Value="@(WatermarkSize.ActualSize)">Actual Size</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="6">
@ -78,7 +79,7 @@ @@ -78,7 +79,7 @@
For="@(() => _model.Width)"
Adornment="Adornment.End"
AdornmentText="%"
Disabled="@(_model.Mode == ChannelWatermarkMode.None || _model.Size == ChannelWatermarkSize.ActualSize)"
Disabled="@(_model.Mode == ChannelWatermarkMode.None || _model.Size == WatermarkSize.ActualSize)"
Immediate="true"/>
</MudItem>
</MudGrid>
@ -169,8 +170,8 @@ @@ -169,8 +170,8 @@
Name = string.Empty,
Mode = ChannelWatermarkMode.Permanent,
Image = string.Empty,
Location = ChannelWatermarkLocation.BottomRight,
Size = ChannelWatermarkSize.Scaled,
Location = WatermarkLocation.BottomRight,
Size = WatermarkSize.Scaled,
Width = 15,
HorizontalMargin = 5,
VerticalMargin = 5,

3
ErsatzTV/Validators/WatermarkEditViewModelValidator.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.ViewModels;
using FluentValidation;
@ -20,7 +21,7 @@ namespace ErsatzTV.Validators @@ -20,7 +21,7 @@ namespace ErsatzTV.Validators
.LessThanOrEqualTo(100)
.When(
vm => vm.Mode != ChannelWatermarkMode.None &&
vm.Size == ChannelWatermarkSize.Scaled);
vm.Size == WatermarkSize.Scaled);
RuleFor(x => x.HorizontalMargin)
.GreaterThanOrEqualTo(0)

5
ErsatzTV/ViewModels/WatermarkEditViewModel.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Application.Watermarks.Commands;
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.ViewModels
{
@ -32,8 +33,8 @@ namespace ErsatzTV.ViewModels @@ -32,8 +33,8 @@ namespace ErsatzTV.ViewModels
public string Image { get; set; }
public ChannelWatermarkMode Mode { get; set; }
public ChannelWatermarkImageSource ImageSource { get; set; }
public ChannelWatermarkLocation Location { get; set; }
public ChannelWatermarkSize Size { get; set; }
public WatermarkLocation Location { get; set; }
public WatermarkSize Size { get; set; }
public int Width { get; set; }
public int HorizontalMargin { get; set; }
public int VerticalMargin { get; set; }

Loading…
Cancel
Save