diff --git a/CHANGELOG.md b/CHANGELOG.md index 2562808a9..e2ad84671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs b/ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs index e3eeedb3f..edb34f3ac 100644 --- a/ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs +++ b/ErsatzTV.Application/Watermarks/Commands/CreateWatermark.cs @@ -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 string Image, ChannelWatermarkMode Mode, ChannelWatermarkImageSource ImageSource, - ChannelWatermarkLocation Location, - ChannelWatermarkSize Size, + WatermarkLocation Location, + WatermarkSize Size, int Width, int HorizontalMargin, int VerticalMargin, diff --git a/ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs b/ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs index 1307aaefe..6ec1744c1 100644 --- a/ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs +++ b/ErsatzTV.Application/Watermarks/Commands/UpdateWatermark.cs @@ -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 string Image, ChannelWatermarkMode Mode, ChannelWatermarkImageSource ImageSource, - ChannelWatermarkLocation Location, - ChannelWatermarkSize Size, + WatermarkLocation Location, + WatermarkSize Size, int Width, int HorizontalMargin, int VerticalMargin, diff --git a/ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs b/ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs index 2a10cff07..15a75a1dc 100644 --- a/ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs +++ b/ErsatzTV.Application/Watermarks/Commands/UpdateWatermarkHandler.cs @@ -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 validation = await Validate(dbContext, request); return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request)); } diff --git a/ErsatzTV.Application/Watermarks/WatermarkViewModel.cs b/ErsatzTV.Application/Watermarks/WatermarkViewModel.cs index ff09dcb89..f2105aaf9 100644 --- a/ErsatzTV.Application/Watermarks/WatermarkViewModel.cs +++ b/ErsatzTV.Application/Watermarks/WatermarkViewModel.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.FFmpeg.State; 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, diff --git a/ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs b/ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs index 04320e6ab..e794dc21a 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/FFmpegComplexFilterBuilderTests.cs @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 // 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 // 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 // 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 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 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 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 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, diff --git a/ErsatzTV.Core/Domain/ChannelWatermark.cs b/ErsatzTV.Core/Domain/ChannelWatermark.cs index c4e81f7b0..4a94ae328 100644 --- a/ErsatzTV.Core/Domain/ChannelWatermark.cs +++ b/ErsatzTV.Core/Domain/ChannelWatermark.cs @@ -1,4 +1,6 @@ -namespace ErsatzTV.Core.Domain +using ErsatzTV.FFmpeg.State; + +namespace ErsatzTV.Core.Domain { public class ChannelWatermark { @@ -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 @@ 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, diff --git a/ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs b/ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs index c3970cfdc..1fe340536 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegComplexFilterBuilder.cs @@ -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 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 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"); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index ecaf95c60..a7616ca05 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -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 hlsRealtime, targetFramerate); + Option watermarkOptions = + await _ffmpegProcessService.GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None); + + Option> 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.None, + playbackSettings.NormalizeLoudness); + var ffmpegVideoStream = new VideoStream( videoStream.Index, videoStream.Codec, @@ -83,25 +111,17 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService videoVersion.RFrameRate, videoPath != audioPath); // still image when paths are different - var inputFiles = new List - { - new(videoPath, new List { ffmpegVideoStream }) - }; - + var videoInputFile = new VideoInputFile(videoPath, new List { 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 = maybeAudioStream.Map( + audioStream => { - inputFiles.Add(new InputFile(audioPath, new List { ffmpegAudioStream })); - } - } + var ffmpegAudioStream = new AudioStream(audioStream.Index, audioStream.Codec, audioStream.Channels); + return new AudioInputFile(audioPath, new List { 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 ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts") : Option.None; + // normalize songs to yuv420p + Option 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 playbackSettings.VideoBitrate, playbackSettings.VideoBufferSize, playbackSettings.VideoTrackTimeScale, - playbackSettings.Deinterlace, - playbackSettings.AudioCodec, - playbackSettings.AudioChannels, - playbackSettings.AudioBitrate, - playbackSettings.AudioBufferSize, - playbackSettings.AudioSampleRate, - videoPath == audioPath ? playbackSettings.AudioDuration : Option.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 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 GetWatermarkInputFile( + Option watermarkOptions, + Option> 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 + { + new( + options.ImageStreamIndex.IfNone(0), + "unknown", + new PixelFormatUnknown(), + new FrameSize(1, 1), + Option.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 ForError( @@ -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 - { - 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 string videoPath, bool boxBlur, Option watermarkPath, - ChannelWatermarkLocation watermarkLocation, + WatermarkLocation watermarkLocation, int horizontalMarginPercent, int verticalMarginPercent, int watermarkWidthPercent) => @@ -231,15 +324,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService verticalMarginPercent, watermarkWidthPercent); - private Process GetProcess(string ffmpegPath, IList inputFiles, FrameState desiredState) + private Process GetProcess( + string ffmpegPath, + Option videoInputFile, + Option audioInputFile, + Option watermarkInputFile, + Option concatInputFile, + FFmpegPipeline pipeline) { - var pipelineBuilder = new PipelineBuilder(inputFiles, FileSystemLayout.FFmpegReportsFolder, _logger); - - FFmpegPipeline pipeline = pipelineBuilder.Build(desiredState); - IEnumerable loggedSteps = pipeline.PipelineSteps.Map(ps => ps.GetType().Name); - IEnumerable loggedVideoFilters = pipeline.VideoFilterSteps.Map(vf => vf.GetType().Name); - IEnumerable loggedAudioFilters = pipeline.AudioFilterSteps.Map(af => af.GetType().Name); + IEnumerable loggedVideoFilters = + videoInputFile.Map(f => f.FilterSteps.Map(vf => vf.GetType().Name)).Flatten(); + IEnumerable 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 loggedVideoFilters ); - IList environmentVariables = CommandGenerator.GenerateEnvironmentVariables(pipeline.PipelineSteps); - IList arguments = CommandGenerator.GenerateArguments(inputFiles, pipeline.PipelineSteps); + IList environmentVariables = + CommandGenerator.GenerateEnvironmentVariables(pipeline.PipelineSteps); + IList arguments = CommandGenerator.GenerateArguments( + videoInputFile, + audioInputFile, + watermarkInputFile, + concatInputFile, + pipeline.PipelineSteps); var startInfo = new ProcessStartInfo { diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 2271f362f..283a03ebb 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -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 string videoPath, bool boxBlur, Option watermarkPath, - ChannelWatermarkLocation watermarkLocation, + WatermarkLocation watermarkLocation, int horizontalMarginPercent, int verticalMarginPercent, int watermarkWidthPercent) @@ -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 private bool NeedToPad(IDisplaySize target, IDisplaySize displaySize) => displaySize.Width != target.Width || displaySize.Height != target.Height; - private async Task GetWatermarkOptions( + internal async Task GetWatermarkOptions( Channel channel, Option globalWatermark, MediaVersion videoVersion, Option watermarkOverride, Option watermarkPath) { - if (videoVersion is BackgroundImageMediaVersion) - { - return new WatermarkOptions(None, None, None, false); - } - if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode && channel.FFmpegProfile.NormalizeVideo) { diff --git a/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs b/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs index e3771591a..182b3e2e5 100644 --- a/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs +++ b/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs @@ -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 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 { 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 SampleAspectRatio = "1:1", Streams = new List { - 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 { diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index 7a3bdd850..819a861a2 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -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 string videoPath, bool boxBlur, Option watermarkPath, - ChannelWatermarkLocation watermarkLocation, + WatermarkLocation watermarkLocation, int horizontalMarginPercent, int verticalMarginPercent, int watermarkWidthPercent); diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs index 6bcb838ec..716537fe3 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderTests.cs @@ -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().Object; - + private readonly ILogger _logger = new Mock().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 - { - new VideoStream(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false), - new AudioStream(1, AudioFormat.Aac, 2) - }); + new List + { new(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false) }); - var inputFiles = new List { testFile }; + var audioInputFile = new AudioInputFile( + "/tmp/whatever.mkv", + new List { new(1, AudioFormat.Aac, 2) }, + new AudioState( + AudioFormat.Aac, + 2, + 320, + 640, + 48, + Option.None, + false)); var desiredState = new FrameState( - false, - HardwareAccelerationMode.None, - Option.None, - Option.None, true, false, - Option.None, - Option.None, - VideoFormat.H264, + VideoFormat.Hevc, new PixelFormatYuv420P(), new FrameSize(1920, 1080), new FrameSize(1920, 1080), @@ -46,70 +49,15 @@ public class PipelineGeneratorTests 2000, 4000, 90_000, - false, - AudioFormat.Aac, - 2, - 320, - 640, - 48, - Option.None, - false, - false, - Option.None, - Option.None, - Option.None, - OutputFormatKind.MpegTs, - Option.None, - Option.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 - { - new VideoStream(0, VideoFormat.H264, new PixelFormatYuv420P(), new FrameSize(1920, 1080), "24", false), - new AudioStream(1, AudioFormat.Aac, 2) - }); - - var inputFiles = new List { testFile }; + false); - var desiredState = new FrameState( + var ffmpegState = new FFmpegState( false, HardwareAccelerationMode.None, Option.None, Option.None, - true, - false, Option.None, Option.None, - VideoFormat.Hevc, - new PixelFormatYuv420P(), - new FrameSize(1920, 1080), - new FrameSize(1920, 1080), - Option.None, - 2000, - 4000, - 90_000, - false, - AudioFormat.Aac, - 2, - 320, - 640, - 48, - Option.None, - false, false, Option.None, Option.None, @@ -119,36 +67,50 @@ public class PipelineGeneratorTests Option.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 - { - 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 inputFiles, FFmpegPipeline pipeline) + private static string PrintCommand( + Option videoInputFile, + Option audioInputFile, + Option watermarkInputFile, + Option concatInputFile, + FFmpegPipeline pipeline) { - IList arguments = CommandGenerator.GenerateArguments(inputFiles, pipeline.PipelineSteps); + IList arguments = CommandGenerator.GenerateArguments( + videoInputFile, + audioInputFile, + watermarkInputFile, + concatInputFile, + pipeline.PipelineSteps); + + var command = string.Join(" ", arguments); + Console.WriteLine($"Generated command: ffmpeg {string.Join(" ", arguments)}"); + + return command; } } diff --git a/ErsatzTV.FFmpeg/CommandGenerator.cs b/ErsatzTV.FFmpeg/CommandGenerator.cs index cf4ceed6b..ce524bfdd 100644 --- a/ErsatzTV.FFmpeg/CommandGenerator.cs +++ b/ErsatzTV.FFmpeg/CommandGenerator.cs @@ -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 } public static IList GenerateArguments( - IEnumerable inputFiles, + Option maybeVideoInputFile, + Option maybeAudioInputFile, + Option maybeWatermarkInputFile, + Option maybeConcatInputFile, IList pipelineSteps) { var arguments = new List(); @@ -20,14 +25,57 @@ public static class CommandGenerator arguments.AddRange(step.GlobalOptions); } - foreach (InputFile inputFile in inputFiles) + var includedPaths = new System.Collections.Generic.HashSet(); + 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) diff --git a/ErsatzTV.FFmpeg/Decoder/AvailableDecoders.cs b/ErsatzTV.FFmpeg/Decoder/AvailableDecoders.cs index a950bb786..5c3e0a1ad 100644 --- a/ErsatzTV.FFmpeg/Decoder/AvailableDecoders.cs +++ b/ErsatzTV.FFmpeg/Decoder/AvailableDecoders.cs @@ -8,9 +8,9 @@ namespace ErsatzTV.FFmpeg.Decoder; public static class AvailableDecoders { - public static Option ForVideoFormat(FrameState currentState, FrameState desiredState, ILogger logger) + public static Option 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 (_, VideoFormat.Undetermined, _) => new DecoderImplicit(), (_, VideoFormat.Copy, _) => new DecoderImplicit(), + (_, VideoFormat.GeneratedImage, _) => new DecoderImplicit(), var (accel, videoFormat, pixelFormat) => LogUnknownDecoder(accel, videoFormat, pixelFormat, logger) }; diff --git a/ErsatzTV.FFmpeg/Decoder/DecoderBase.cs b/ErsatzTV.FFmpeg/Decoder/DecoderBase.cs index 25218bcf7..d44de6643 100644 --- a/ErsatzTV.FFmpeg/Decoder/DecoderBase.cs +++ b/ErsatzTV.FFmpeg/Decoder/DecoderBase.cs @@ -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; } diff --git a/ErsatzTV.FFmpeg/Decoder/IDecoder.cs b/ErsatzTV.FFmpeg/Decoder/IDecoder.cs index 607cc9c81..c0f60e154 100644 --- a/ErsatzTV.FFmpeg/Decoder/IDecoder.cs +++ b/ErsatzTV.FFmpeg/Decoder/IDecoder.cs @@ -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; } } diff --git a/ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs b/ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs index 98e7361b9..3002d6f5a 100644 --- a/ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs +++ b/ErsatzTV.FFmpeg/Encoder/AvailableEncoders.cs @@ -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; public static class AvailableEncoders { - public static Option ForVideoFormat(FrameState currentState, FrameState desiredState, ILogger logger) => - (desiredState.HardwareAccelerationMode, desiredState.VideoFormat) switch + public static Option 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 return Option.None; } - public static Option ForAudioFormat(FrameState desiredState, ILogger logger) + public static Option ForAudioFormat(AudioState desiredState, ILogger logger) { return desiredState.AudioFormat.Match( audioFormat => diff --git a/ErsatzTV.FFmpeg/Encoder/EncoderAac.cs b/ErsatzTV.FFmpeg/Encoder/EncoderAac.cs index 91d8e7f44..34ad343e8 100644 --- a/ErsatzTV.FFmpeg/Encoder/EncoderAac.cs +++ b/ErsatzTV.FFmpeg/Encoder/EncoderAac.cs @@ -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; diff --git a/ErsatzTV.FFmpeg/Encoder/EncoderAc3.cs b/ErsatzTV.FFmpeg/Encoder/EncoderAc3.cs index 60013a121..6f0d51c62 100644 --- a/ErsatzTV.FFmpeg/Encoder/EncoderAc3.cs +++ b/ErsatzTV.FFmpeg/Encoder/EncoderAc3.cs @@ -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; diff --git a/ErsatzTV.FFmpeg/Encoder/EncoderBase.cs b/ErsatzTV.FFmpeg/Encoder/EncoderBase.cs index f17014077..2db8f3d13 100644 --- a/ErsatzTV.FFmpeg/Encoder/EncoderBase.cs +++ b/ErsatzTV.FFmpeg/Encoder/EncoderBase.cs @@ -9,7 +9,7 @@ public abstract class EncoderBase : IEncoder public IList InputOptions(InputFile inputFile) => Array.Empty(); public IList FilterOptions => Array.Empty(); public virtual IList OutputOptions => new List { 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; } diff --git a/ErsatzTV.FFmpeg/Environment/FFReportVariable.cs b/ErsatzTV.FFmpeg/Environment/FFReportVariable.cs index 779d0be62..a77bfefa0 100644 --- a/ErsatzTV.FFmpeg/Environment/FFReportVariable.cs +++ b/ErsatzTV.FFmpeg/Environment/FFReportVariable.cs @@ -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 _inputFiles; + private readonly Option _maybeConcatInputFile; - public FFReportVariable(string reportsFolder, IList inputFiles) + public FFReportVariable(string reportsFolder, Option maybeConcatInputFile) { _reportsFolder = reportsFolder; - _inputFiles = inputFiles; + _maybeConcatInputFile = maybeConcatInputFile; } public IList EnvironmentVariables { get { - string fileName = _inputFiles.OfType().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 public IList InputOptions(InputFile inputFile) => Array.Empty(); public IList FilterOptions => Array.Empty(); public IList OutputOptions => Array.Empty(); - public FrameState NextState(FrameState currentState) => currentState with { SaveReport = true }; + public FrameState NextState(FrameState currentState) => currentState; } diff --git a/ErsatzTV.FFmpeg/Environment/LibvaDriverNameVariable.cs b/ErsatzTV.FFmpeg/Environment/LibvaDriverNameVariable.cs index 3944d12e5..315e36f77 100644 --- a/ErsatzTV.FFmpeg/Environment/LibvaDriverNameVariable.cs +++ b/ErsatzTV.FFmpeg/Environment/LibvaDriverNameVariable.cs @@ -18,5 +18,5 @@ public class LibvaDriverNameVariable : IPipelineStep public IList InputOptions(InputFile inputFile) => Array.Empty(); public IList FilterOptions => Array.Empty(); public IList OutputOptions => Array.Empty(); - public FrameState NextState(FrameState currentState) => currentState with { VaapiDriver = _driverName }; + public FrameState NextState(FrameState currentState) => currentState; } diff --git a/ErsatzTV.FFmpeg/FFmpegPipeline.cs b/ErsatzTV.FFmpeg/FFmpegPipeline.cs index 937c0888a..91cd7f702 100644 --- a/ErsatzTV.FFmpeg/FFmpegPipeline.cs +++ b/ErsatzTV.FFmpeg/FFmpegPipeline.cs @@ -1,6 +1,3 @@ namespace ErsatzTV.FFmpeg; -public record FFmpegPipeline( - IList PipelineSteps, - IList VideoFilterSteps, - IList AudioFilterSteps); +public record FFmpegPipeline(IList PipelineSteps); diff --git a/ErsatzTV.FFmpeg/FFmpegState.cs b/ErsatzTV.FFmpeg/FFmpegState.cs new file mode 100644 index 000000000..41c07d856 --- /dev/null +++ b/ErsatzTV.FFmpeg/FFmpegState.cs @@ -0,0 +1,38 @@ +using ErsatzTV.FFmpeg.OutputFormat; +using LanguageExt; + +namespace ErsatzTV.FFmpeg; + +public record FFmpegState( + bool SaveReport, + HardwareAccelerationMode HardwareAccelerationMode, + Option VaapiDriver, + Option VaapiDevice, + Option Start, + Option Finish, + bool DoNotMapMetadata, + Option MetadataServiceProvider, + Option MetadataServiceName, + Option MetadataAudioLanguage, + OutputFormatKind OutputFormat, + Option HlsPlaylistPath, + Option HlsSegmentTemplate, + long PtsOffset) +{ + public static FFmpegState Concat(bool saveReport, string channelName) => + new( + saveReport, + HardwareAccelerationMode.None, + Option.None, + Option.None, + Option.None, + Option.None, + true, // do not map metadata + "ErsatzTV", + channelName, + Option.None, + OutputFormatKind.MpegTs, + Option.None, + Option.None, + 0); +} diff --git a/ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs b/ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs index ba6e868f2..038ef569d 100644 --- a/ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/AudioPadFilter.cs @@ -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; } diff --git a/ErsatzTV.FFmpeg/Filter/AvailableOverlayFilters.cs b/ErsatzTV.FFmpeg/Filter/AvailableOverlayFilters.cs new file mode 100644 index 000000000..a8d0741d7 --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/AvailableOverlayFilters.cs @@ -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) + }; +} diff --git a/ErsatzTV.FFmpeg/Filter/BaseFilter.cs b/ErsatzTV.FFmpeg/Filter/BaseFilter.cs index a4f438996..d7bf1c49d 100644 --- a/ErsatzTV.FFmpeg/Filter/BaseFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/BaseFilter.cs @@ -6,7 +6,7 @@ public abstract class BaseFilter : IPipelineFilterStep { public IList EnvironmentVariables => Array.Empty(); public virtual IList GlobalOptions => Array.Empty(); - public virtual IList InputOptions(InputFile inputFile) => Array.Empty(); + public IList InputOptions(InputFile inputFile) => Array.Empty(); public virtual IList FilterOptions => Array.Empty(); public virtual IList OutputOptions => Array.Empty(); public abstract FrameState NextState(FrameState currentState); diff --git a/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs b/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs index dc8039b70..06c038afc 100644 --- a/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs @@ -1,62 +1,135 @@ using ErsatzTV.FFmpeg.Environment; +using LanguageExt; namespace ErsatzTV.FFmpeg.Filter; public class ComplexFilter : IPipelineStep { - private readonly IList _inputFiles; - private readonly IList _audioFilters; - private readonly IList _videoFilters; + private readonly FFmpegState _ffmpegState; + private readonly Option _maybeVideoInputFile; + private readonly Option _maybeAudioInputFile; + private readonly Option _maybeWatermarkInputFile; + private readonly FrameSize _resolution; - public ComplexFilter(IList inputFiles, IList audioFilters, IList videoFilters) + public ComplexFilter( + FFmpegState ffmpegState, + Option maybeVideoInputFile, + Option maybeAudioInputFile, + Option maybeWatermarkInputFile, + FrameSize resolution) { - _inputFiles = inputFiles; - _audioFilters = audioFilters; - _videoFilters = videoFilters; + _ffmpegState = ffmpegState; + _maybeVideoInputFile = maybeVideoInputFile; + _maybeAudioInputFile = maybeAudioInputFile; + _maybeWatermarkInputFile = maybeWatermarkInputFile; + _resolution = resolution; } private IList Arguments() { var audioLabel = "0:a"; var videoLabel = "0:v"; + string watermarkLabel; var result = new List(); 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(); + 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 { 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 }); } diff --git a/ErsatzTV.FFmpeg/Filter/Cuda/OverlayCudaFilter.cs b/ErsatzTV.FFmpeg/Filter/Cuda/OverlayCudaFilter.cs new file mode 100644 index 000000000..abd947d24 --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/Cuda/OverlayCudaFilter.cs @@ -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}"; +} diff --git a/ErsatzTV.FFmpeg/Filter/HardwareUploadFilter.cs b/ErsatzTV.FFmpeg/Filter/HardwareUploadFilter.cs new file mode 100644 index 000000000..2ad1f62b7 --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/HardwareUploadFilter.cs @@ -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" + }; +} diff --git a/ErsatzTV.FFmpeg/Filter/NormalizeLoudnessFilter.cs b/ErsatzTV.FFmpeg/Filter/NormalizeLoudnessFilter.cs index 814414380..9fa000514 100644 --- a/ErsatzTV.FFmpeg/Filter/NormalizeLoudnessFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/NormalizeLoudnessFilter.cs @@ -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; } diff --git a/ErsatzTV.FFmpeg/Filter/OverlayFilter.cs b/ErsatzTV.FFmpeg/Filter/OverlayFilter.cs new file mode 100644 index 000000000..b6c72fdfd --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/OverlayFilter.cs @@ -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}" + }; + } + } +} diff --git a/ErsatzTV.FFmpeg/Filter/PixelFormatFilter.cs b/ErsatzTV.FFmpeg/Filter/PixelFormatFilter.cs new file mode 100644 index 000000000..b14f7fa55 --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/PixelFormatFilter.cs @@ -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}"; +} diff --git a/ErsatzTV.FFmpeg/Filter/WatermarkFadeFilter.cs b/ErsatzTV.FFmpeg/Filter/WatermarkFadeFilter.cs new file mode 100644 index 000000000..70d144db2 --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/WatermarkFadeFilter.cs @@ -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})'"; + } + } +} diff --git a/ErsatzTV.FFmpeg/Filter/WatermarkOpacityFilter.cs b/ErsatzTV.FFmpeg/Filter/WatermarkOpacityFilter.cs new file mode 100644 index 000000000..7c9995d30 --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/WatermarkOpacityFilter.cs @@ -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}"; + } + } +} diff --git a/ErsatzTV.FFmpeg/Filter/WatermarkPixelFormatFilter.cs b/ErsatzTV.FFmpeg/Filter/WatermarkPixelFormatFilter.cs new file mode 100644 index 000000000..34f85171c --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/WatermarkPixelFormatFilter.cs @@ -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 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); + } + } +} diff --git a/ErsatzTV.FFmpeg/Filter/WatermarkScaleFilter.cs b/ErsatzTV.FFmpeg/Filter/WatermarkScaleFilter.cs new file mode 100644 index 000000000..3d59d4d91 --- /dev/null +++ b/ErsatzTV.FFmpeg/Filter/WatermarkScaleFilter.cs @@ -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"; + } + } +} diff --git a/ErsatzTV.FFmpeg/Format/ConcatInputFormat.cs b/ErsatzTV.FFmpeg/Format/ConcatInputFormat.cs index c4acc98d2..50e86c169 100644 --- a/ErsatzTV.FFmpeg/Format/ConcatInputFormat.cs +++ b/ErsatzTV.FFmpeg/Format/ConcatInputFormat.cs @@ -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 EnvironmentVariables => Array.Empty(); public IList GlobalOptions => Array.Empty(); @@ -18,4 +19,9 @@ public class ConcatInputFormat : IPipelineStep public IList FilterOptions => Array.Empty(); public IList OutputOptions => Array.Empty(); public FrameState NextState(FrameState currentState) => currentState; + public bool AppliesTo(AudioInputFile audioInputFile) => false; + + public bool AppliesTo(VideoInputFile videoInputFile) => false; + + public bool AppliesTo(ConcatInputFile concatInputFile) => true; } diff --git a/ErsatzTV.FFmpeg/Format/VideoFormat.cs b/ErsatzTV.FFmpeg/Format/VideoFormat.cs index 87e80cc2a..37b02c6b4 100644 --- a/ErsatzTV.FFmpeg/Format/VideoFormat.cs +++ b/ErsatzTV.FFmpeg/Format/VideoFormat.cs @@ -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 = ""; } diff --git a/ErsatzTV.FFmpeg/FrameState.cs b/ErsatzTV.FFmpeg/FrameState.cs index d949430d4..bd36305b2 100644 --- a/ErsatzTV.FFmpeg/FrameState.cs +++ b/ErsatzTV.FFmpeg/FrameState.cs @@ -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 VaapiDriver, - Option VaapiDevice, bool Realtime, bool InfiniteLoop, - Option Start, - Option Finish, string VideoFormat, Option PixelFormat, FrameSize ScaledSize, @@ -22,55 +15,4 @@ public record FrameState( Option VideoBufferSize, Option VideoTrackTimeScale, bool Deinterlaced, - Option AudioFormat, - Option AudioChannels, - Option AudioBitrate, - Option AudioBufferSize, - Option AudioSampleRate, - Option AudioDuration, - bool NormalizeLoudness, - bool DoNotMapMetadata, - Option MetadataServiceProvider, - Option MetadataServiceName, - Option MetadataAudioLanguage, - OutputFormatKind OutputFormat, - Option HlsPlaylistPath, - Option HlsSegmentTemplate, - long PtsOffset, - FrameDataLocation FrameDataLocation = FrameDataLocation.Unknown) -{ - public static FrameState Concat(bool saveReport, string channelName, FrameSize resolution) => - new( - saveReport, - HardwareAccelerationMode.None, - Option.None, - Option.None, - true, // realtime - true, // infinite loop - Option.None, - Option.None, - Format.VideoFormat.Undetermined, - Option.None, - resolution, - resolution, - Option.None, - Option.None, - Option.None, - Option.None, - false, - Option.None, - Option.None, - Option.None, - Option.None, - Option.None, - Option.None, - false, // normalize loudness - true, // do not map metadata - "ErsatzTV", - channelName, - Option.None, - OutputFormatKind.MpegTs, - Option.None, - Option.None, - 0); -} + FrameDataLocation FrameDataLocation = FrameDataLocation.Unknown); \ No newline at end of file diff --git a/ErsatzTV.FFmpeg/InputFile.cs b/ErsatzTV.FFmpeg/InputFile.cs index 5b263d393..20a289315 100644 --- a/ErsatzTV.FFmpeg/InputFile.cs +++ b/ErsatzTV.FFmpeg/InputFile.cs @@ -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 Streams); +public abstract record InputFile(string Path, IList Streams) +{ + public List InputOptions { get; } = new(); + public List FilterSteps { get; } = new(); +} + +public record ConcatInputFile(string Url, FrameSize Resolution) : InputFile( + Url, + new List + { + new VideoStream( + 0, + string.Empty, + Option.None, + Resolution, + Option.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 +public record AudioInputFile(string Path, IList AudioStreams, AudioState DesiredState) : InputFile( + Path, + AudioStreams.Cast().ToList()) { - new VideoStream( - 0, - string.Empty, - Option.None, - Resolution, - Option.None, - false) -}); \ No newline at end of file + public void AddOption(IInputOption option) + { + if (option.AppliesTo(this)) + { + InputOptions.Add(option); + } + } +} + +public record VideoInputFile(string Path, IList VideoStreams) : InputFile( + Path, + VideoStreams.Cast().ToList()) +{ + public void AddOption(IInputOption option) + { + if (option.AppliesTo(this)) + { + InputOptions.Add(option); + } + } +} + +public record WatermarkInputFile + (string Path, IList VideoStreams, WatermarkState DesiredState) : VideoInputFile(Path, VideoStreams); diff --git a/ErsatzTV.FFmpeg/Option/AudioBitrateOutputOption.cs b/ErsatzTV.FFmpeg/Option/AudioBitrateOutputOption.cs index 8cce4605f..a02693246 100644 --- a/ErsatzTV.FFmpeg/Option/AudioBitrateOutputOption.cs +++ b/ErsatzTV.FFmpeg/Option/AudioBitrateOutputOption.cs @@ -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 - }; } diff --git a/ErsatzTV.FFmpeg/Option/AudioBufferSizeOutputOption.cs b/ErsatzTV.FFmpeg/Option/AudioBufferSizeOutputOption.cs index 74d964f9d..ab32697eb 100644 --- a/ErsatzTV.FFmpeg/Option/AudioBufferSizeOutputOption.cs +++ b/ErsatzTV.FFmpeg/Option/AudioBufferSizeOutputOption.cs @@ -13,9 +13,4 @@ public class AudioBufferSizeOutputOption : OutputOption { "-bufsize:a", $"{_decoderBufferSize}k" }; - - public override FrameState NextState(FrameState currentState) => currentState with - { - AudioBufferSize = _decoderBufferSize - }; } diff --git a/ErsatzTV.FFmpeg/Option/AudioChannelsOutputOption.cs b/ErsatzTV.FFmpeg/Option/AudioChannelsOutputOption.cs index b2d6e1273..ca76ff75a 100644 --- a/ErsatzTV.FFmpeg/Option/AudioChannelsOutputOption.cs +++ b/ErsatzTV.FFmpeg/Option/AudioChannelsOutputOption.cs @@ -10,6 +10,4 @@ public class AudioChannelsOutputOption : OutputOption } public override IList OutputOptions => new List { "-ac", _channels.ToString() }; - - public override FrameState NextState(FrameState currentState) => currentState with { AudioChannels = _channels }; } diff --git a/ErsatzTV.FFmpeg/Option/AudioSampleRateOutputOption.cs b/ErsatzTV.FFmpeg/Option/AudioSampleRateOutputOption.cs index d56f749d3..89c144fe1 100644 --- a/ErsatzTV.FFmpeg/Option/AudioSampleRateOutputOption.cs +++ b/ErsatzTV.FFmpeg/Option/AudioSampleRateOutputOption.cs @@ -10,7 +10,4 @@ public class AudioSampleRateOutputOption : OutputOption } public override IList OutputOptions => new List { "-ar", $"{_sampleRate}k" }; - - public override FrameState NextState(FrameState currentState) => - currentState with { AudioSampleRate = _sampleRate }; } diff --git a/ErsatzTV.FFmpeg/Option/DoNotIgnoreLoopInputOption.cs b/ErsatzTV.FFmpeg/Option/DoNotIgnoreLoopInputOption.cs new file mode 100644 index 000000000..cdb7f6b2c --- /dev/null +++ b/ErsatzTV.FFmpeg/Option/DoNotIgnoreLoopInputOption.cs @@ -0,0 +1,21 @@ +using ErsatzTV.FFmpeg.Environment; + +namespace ErsatzTV.FFmpeg.Option; + +public class DoNotIgnoreLoopInputOption : IInputOption +{ + public IList EnvironmentVariables => Array.Empty(); + public IList GlobalOptions => Array.Empty(); + + public IList InputOptions(InputFile inputFile) => new List { "-ignore_loop", "0" }; + + public IList FilterOptions => Array.Empty(); + public IList OutputOptions => Array.Empty(); + 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; +} diff --git a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/AvailableHardwareAccelerationOptions.cs b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/AvailableHardwareAccelerationOptions.cs index 19ac3a91b..e2bc5276c 100644 --- a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/AvailableHardwareAccelerationOptions.cs +++ b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/AvailableHardwareAccelerationOptions.cs @@ -15,6 +15,7 @@ public static class AvailableHardwareAccelerationOptions HardwareAccelerationMode.Qsv => new QsvHardwareAccelerationOption(), HardwareAccelerationMode.Vaapi => GetVaapiAcceleration(vaapiDevice, logger), HardwareAccelerationMode.VideoToolbox => new VideoToolboxHardwareAccelerationOption(), + HardwareAccelerationMode.None => Option.None, _ => LogUnknownMode(mode, logger) }; diff --git a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/CudaHardwareAccelerationOption.cs b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/CudaHardwareAccelerationOption.cs index 30953bfd9..b2cf70c6d 100644 --- a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/CudaHardwareAccelerationOption.cs +++ b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/CudaHardwareAccelerationOption.cs @@ -3,9 +3,4 @@ public class CudaHardwareAccelerationOption : GlobalOption { public override IList GlobalOptions => new List { "-hwaccel", "cuda" }; - - public override FrameState NextState(FrameState currentState) => currentState with - { - HardwareAccelerationMode = HardwareAccelerationMode.Nvenc - }; } diff --git a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/QsvHardwareAccelerationOption.cs b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/QsvHardwareAccelerationOption.cs index af6a86894..1ba5d4a3e 100644 --- a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/QsvHardwareAccelerationOption.cs +++ b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/QsvHardwareAccelerationOption.cs @@ -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) { diff --git a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/VaapiHardwareAccelerationOption.cs b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/VaapiHardwareAccelerationOption.cs index 331ac3507..cd4eec585 100644 --- a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/VaapiHardwareAccelerationOption.cs +++ b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/VaapiHardwareAccelerationOption.cs @@ -14,7 +14,6 @@ public class VaapiHardwareAccelerationOption : GlobalOption public override FrameState NextState(FrameState currentState) => currentState with { - HardwareAccelerationMode = HardwareAccelerationMode.Vaapi, FrameDataLocation = FrameDataLocation.Hardware }; } diff --git a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/VideoToolboxHardwareAccelerationOption.cs b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/VideoToolboxHardwareAccelerationOption.cs index 1eb0f3cd1..9f64d7644 100644 --- a/ErsatzTV.FFmpeg/Option/HardwareAcceleration/VideoToolboxHardwareAccelerationOption.cs +++ b/ErsatzTV.FFmpeg/Option/HardwareAcceleration/VideoToolboxHardwareAccelerationOption.cs @@ -3,9 +3,4 @@ public class VideoToolboxHardwareAccelerationOption : GlobalOption { public override IList GlobalOptions => new List { "-hwaccel", "videotoolbox" }; - - public override FrameState NextState(FrameState currentState) => currentState with - { - HardwareAccelerationMode = HardwareAccelerationMode.VideoToolbox - }; } diff --git a/ErsatzTV.FFmpeg/Option/IInputOption.cs b/ErsatzTV.FFmpeg/Option/IInputOption.cs new file mode 100644 index 000000000..6f757d216 --- /dev/null +++ b/ErsatzTV.FFmpeg/Option/IInputOption.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.FFmpeg.Option; + +public interface IInputOption : IPipelineStep +{ + bool AppliesTo(AudioInputFile audioInputFile); + bool AppliesTo(VideoInputFile videoInputFile); + bool AppliesTo(ConcatInputFile concatInputFile); +} diff --git a/ErsatzTV.FFmpeg/Option/InfiniteLoopInputOption.cs b/ErsatzTV.FFmpeg/Option/InfiniteLoopInputOption.cs index 124562299..4c6366da4 100644 --- a/ErsatzTV.FFmpeg/Option/InfiniteLoopInputOption.cs +++ b/ErsatzTV.FFmpeg/Option/InfiniteLoopInputOption.cs @@ -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 EnvironmentVariables => Array.Empty(); public IList GlobalOptions => Array.Empty(); + public IList InputOptions(InputFile inputFile) { - // only loop video media - if (inputFile.Streams.OfType().Any()) + // loop 1 for still images + if (inputFile.Streams.OfType().Any(s => s.StillImage)) { - // loop 1 for still images - if (inputFile.Streams.OfType().Any(s => s.StillImage)) - { - return new List { "-loop", "1" }; - } - - // stream_loop for looped video i.e. filler - return new List { "-stream_loop", "-1" }; + return new List { "-loop", "1" }; } - return Array.Empty(); + // stream_loop for looped video i.e. filler + return new List { "-stream_loop", "-1" }; } public IList FilterOptions => Array.Empty(); public IList OutputOptions => - _currentState.HardwareAccelerationMode is HardwareAccelerationMode.Qsv or HardwareAccelerationMode.Vaapi + _hardwareAccelerationMode is HardwareAccelerationMode.Qsv or HardwareAccelerationMode.Vaapi ? new List { "-noautoscale" } : Array.Empty(); 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; } diff --git a/ErsatzTV.FFmpeg/Option/Metadata/DoNotMapMetadataOutputOption.cs b/ErsatzTV.FFmpeg/Option/Metadata/DoNotMapMetadataOutputOption.cs index 1f8d942de..49c954c5d 100644 --- a/ErsatzTV.FFmpeg/Option/Metadata/DoNotMapMetadataOutputOption.cs +++ b/ErsatzTV.FFmpeg/Option/Metadata/DoNotMapMetadataOutputOption.cs @@ -3,6 +3,4 @@ public class DoNotMapMetadataOutputOption : OutputOption { public override IList OutputOptions => new List { "-map_metadata", "-1" }; - - public override FrameState NextState(FrameState currentState) => currentState with { DoNotMapMetadata = true }; } diff --git a/ErsatzTV.FFmpeg/Option/Metadata/MetadataAudioLanguageOutputOption.cs b/ErsatzTV.FFmpeg/Option/Metadata/MetadataAudioLanguageOutputOption.cs index d7b9ba65c..5324ef413 100644 --- a/ErsatzTV.FFmpeg/Option/Metadata/MetadataAudioLanguageOutputOption.cs +++ b/ErsatzTV.FFmpeg/Option/Metadata/MetadataAudioLanguageOutputOption.cs @@ -11,9 +11,4 @@ public class MetadataAudioLanguageOutputOption : OutputOption public override IList OutputOptions => new List { "-metadata:s:a:0", $"language={_audioLanguage}" }; - - public override FrameState NextState(FrameState currentState) => currentState with - { - MetadataAudioLanguage = _audioLanguage - }; } diff --git a/ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceNameOutputOption.cs b/ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceNameOutputOption.cs index 099ba40af..86a089952 100644 --- a/ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceNameOutputOption.cs +++ b/ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceNameOutputOption.cs @@ -11,9 +11,4 @@ public class MetadataServiceNameOutputOption : OutputOption public override IList OutputOptions => new List { "-metadata", $"service_name=\"{_serviceName}\"" }; - - public override FrameState NextState(FrameState currentState) => currentState with - { - MetadataServiceName = _serviceName - }; } diff --git a/ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceProviderOutputOption.cs b/ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceProviderOutputOption.cs index 8e9dd27d6..367ab6f48 100644 --- a/ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceProviderOutputOption.cs +++ b/ErsatzTV.FFmpeg/Option/Metadata/MetadataServiceProviderOutputOption.cs @@ -11,9 +11,4 @@ public class MetadataServiceProviderOutputOption : OutputOption public override IList OutputOptions => new List { "-metadata", $"service_provider=\"{_serviceProvider}\"" }; - - public override FrameState NextState(FrameState currentState) => currentState with - { - MetadataServiceProvider = _serviceProvider - }; } diff --git a/ErsatzTV.FFmpeg/Option/OutputTsOffsetOption.cs b/ErsatzTV.FFmpeg/Option/OutputTsOffsetOption.cs index 5cb7d1f6d..fe7680711 100644 --- a/ErsatzTV.FFmpeg/Option/OutputTsOffsetOption.cs +++ b/ErsatzTV.FFmpeg/Option/OutputTsOffsetOption.cs @@ -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 }; } diff --git a/ErsatzTV.FFmpeg/Option/PixelFormatOutputOption.cs b/ErsatzTV.FFmpeg/Option/PixelFormatOutputOption.cs new file mode 100644 index 000000000..4b60fc05a --- /dev/null +++ b/ErsatzTV.FFmpeg/Option/PixelFormatOutputOption.cs @@ -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 OutputOptions => new List + { + "-pix_fmt", _pixelFormat.FFmpegName + }; + + public override FrameState NextState(FrameState currentState) => + currentState with { PixelFormat = Some(_pixelFormat) }; +} diff --git a/ErsatzTV.FFmpeg/Option/RealtimeInputOption.cs b/ErsatzTV.FFmpeg/Option/RealtimeInputOption.cs index 3cb2d047e..93cf9c43e 100644 --- a/ErsatzTV.FFmpeg/Option/RealtimeInputOption.cs +++ b/ErsatzTV.FFmpeg/Option/RealtimeInputOption.cs @@ -2,7 +2,7 @@ namespace ErsatzTV.FFmpeg.Option; -public class RealtimeInputOption : IPipelineStep +public class RealtimeInputOption : IInputOption { public IList EnvironmentVariables => Array.Empty(); @@ -10,9 +10,17 @@ public class RealtimeInputOption : IPipelineStep // so we force a single thread here // public IList GlobalOptions => new List { "-threads", "1" }; public IList GlobalOptions => Array.Empty(); - + public IList InputOptions(InputFile inputFile) => new List { "-re" }; + public IList FilterOptions => Array.Empty(); public IList OutputOptions => Array.Empty(); 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; } diff --git a/ErsatzTV.FFmpeg/Option/StreamSeekInputOption.cs b/ErsatzTV.FFmpeg/Option/StreamSeekInputOption.cs index 21ba5d3e0..4185d32c2 100644 --- a/ErsatzTV.FFmpeg/Option/StreamSeekInputOption.cs +++ b/ErsatzTV.FFmpeg/Option/StreamSeekInputOption.cs @@ -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 public IList EnvironmentVariables => Array.Empty(); public IList GlobalOptions => Array.Empty(); + public IList InputOptions(InputFile inputFile) => new List { "-ss", $"{_start:c}" }; + public IList FilterOptions => Array.Empty(); + public IList OutputOptions => Array.Empty(); + public FrameState NextState(FrameState currentState) => currentState; + + public bool AppliesTo(AudioInputFile audioInputFile) => true; // don't seek into a still image - public IList InputOptions(InputFile inputFile) => - _start == TimeSpan.Zero || inputFile.Streams.OfType().Any(s => s.StillImage) - ? Array.Empty() - : new List { "-ss", $"{_start:c}" }; + public bool AppliesTo(VideoInputFile videoInputFile) => videoInputFile.VideoStreams.All(s => !s.StillImage); - public IList FilterOptions => Array.Empty(); - public IList OutputOptions => Array.Empty(); - public FrameState NextState(FrameState currentState) => currentState with { Start = _start }; + // never seek when concatenating + public bool AppliesTo(ConcatInputFile concatInputFile) => false; } diff --git a/ErsatzTV.FFmpeg/Option/TimeLimitOutputOption.cs b/ErsatzTV.FFmpeg/Option/TimeLimitOutputOption.cs index ff03f895a..ed75cb5c3 100644 --- a/ErsatzTV.FFmpeg/Option/TimeLimitOutputOption.cs +++ b/ErsatzTV.FFmpeg/Option/TimeLimitOutputOption.cs @@ -11,11 +11,10 @@ public class TimeLimitOutputOption : IPipelineStep _finish = finish; } - public FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Unknown; public IList EnvironmentVariables => Array.Empty(); public IList GlobalOptions => Array.Empty(); public IList InputOptions(InputFile inputFile) => Array.Empty(); public IList FilterOptions => Array.Empty(); public IList OutputOptions => new List { "-t", $"{_finish:c}" }; - public FrameState NextState(FrameState currentState) => currentState with { Finish = _finish }; + public FrameState NextState(FrameState currentState) => currentState; } diff --git a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs index 4ad31f5b6..54042dfe0 100644 --- a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs +++ b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs @@ -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 return frameRate; } - - public FrameState NextState(FrameState currentState) => currentState with - { - OutputFormat = OutputFormatKind.Hls, - HlsPlaylistPath = _playlistPath, - HlsSegmentTemplate = _segmentTemplate - }; } diff --git a/ErsatzTV.FFmpeg/PipelineBuilder.cs b/ErsatzTV.FFmpeg/PipelineBuilder.cs index fefbafa42..e56f28a01 100644 --- a/ErsatzTV.FFmpeg/PipelineBuilder.cs +++ b/ErsatzTV.FFmpeg/PipelineBuilder.cs @@ -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 _pipelineSteps; - private readonly List _audioFilterSteps; - private readonly List _videoFilterSteps; - private readonly IList _inputFiles; + private readonly Option _videoInputFile; + private readonly Option _audioInputFile; + private readonly Option _watermarkInputFile; private readonly string _reportsFolder; private readonly ILogger _logger; - public PipelineBuilder(IList inputFiles, string reportsFolder, ILogger logger) + public PipelineBuilder( + Option videoInputFile, + Option audioInputFile, + Option watermarkInputFile, + string reportsFolder, + ILogger logger) { _pipelineSteps = new List { @@ -37,20 +44,51 @@ public class PipelineBuilder new ClosedGopOutputOption(), }; - _audioFilterSteps = new List(); - _videoFilterSteps = new List(); - - _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() - .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 ? new NoSceneDetectOutputOption(0) : new NoSceneDetectOutputOption(1_000_000_000)); - var allAudioStreams = _inputFiles.SelectMany(f => f.Streams) - .Filter(s => s.Kind == StreamKind.Audio) - .OfType() - .ToList(); + if (ffmpegState.SaveReport) + { + _pipelineSteps.Add(new FFReportVariable(_reportsFolder, None)); + } - VideoStream videoStream = allVideoStreams.Head(); - Option 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 initialFrameRate = Option.None; foreach (string frameRateString in videoStream.FrameRate) @@ -77,14 +125,8 @@ public class PipelineBuilder } var currentState = new FrameState( - false, // save report - HardwareAccelerationMode.None, - Option.None, - Option.None, false, // realtime false, // infinite loop - Option.None, - Option.None, videoStream.Codec, videoStream.PixelFormat, videoStream.FrameSize, @@ -93,52 +135,8 @@ public class PipelineBuilder Option.None, Option.None, Option.None, - false, // deinterlace - audioStream.Map(a => a.Codec), - audioStream.Map(a => a.Channels), - Option.None, - Option.None, - Option.None, - Option.None, - false, - false, - Option.None, - Option.None, - Option.None, - OutputFormatKind.None, - Option.None, - Option.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 } else { - if (currentState.HardwareAccelerationMode != desiredState.HardwareAccelerationMode) - { - Option maybeAccel = AvailableHardwareAccelerationOptions.ForMode( - desiredState.HardwareAccelerationMode, - desiredState.VaapiDevice, - _logger); + Option 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().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 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 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().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 // 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 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 break; } - // add a complex filter unless we are concatenating - if (!_inputFiles.OfType().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 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; - } } diff --git a/ErsatzTV.FFmpeg/State/AudioState.cs b/ErsatzTV.FFmpeg/State/AudioState.cs new file mode 100644 index 000000000..648f4aa37 --- /dev/null +++ b/ErsatzTV.FFmpeg/State/AudioState.cs @@ -0,0 +1,12 @@ +using LanguageExt; + +namespace ErsatzTV.FFmpeg.State; + +public record AudioState( + Option AudioFormat, + Option AudioChannels, + Option AudioBitrate, + Option AudioBufferSize, + Option AudioSampleRate, + Option AudioDuration, + bool NormalizeLoudness); diff --git a/ErsatzTV.FFmpeg/State/WatermarkState.cs b/ErsatzTV.FFmpeg/State/WatermarkState.cs new file mode 100644 index 000000000..b95c5bf6d --- /dev/null +++ b/ErsatzTV.FFmpeg/State/WatermarkState.cs @@ -0,0 +1,42 @@ +using LanguageExt; + +namespace ErsatzTV.FFmpeg.State; + +public record WatermarkState( + Option> 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 +} \ No newline at end of file diff --git a/ErsatzTV/Pages/WatermarkEditor.razor b/ErsatzTV/Pages/WatermarkEditor.razor index 4a9e267cc..ff5f7f2a7 100644 --- a/ErsatzTV/Pages/WatermarkEditor.razor +++ b/ErsatzTV/Pages/WatermarkEditor.razor @@ -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 _logger @inject ISnackbar _snackbar @@ -55,22 +56,22 @@ - Bottom Right - Bottom Middle - Bottom Left - Left Middle - Top Left - Top Middle - Top Right - Right Middle + Bottom Right + Bottom Middle + Bottom Left + Left Middle + Top Left + Top Middle + Top Right + Right Middle - Scaled - Actual Size + Scaled + Actual Size @@ -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"/> @@ -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, diff --git a/ErsatzTV/Validators/WatermarkEditViewModelValidator.cs b/ErsatzTV/Validators/WatermarkEditViewModelValidator.cs index 0914a10a6..dd6b6bd38 100644 --- a/ErsatzTV/Validators/WatermarkEditViewModelValidator.cs +++ b/ErsatzTV/Validators/WatermarkEditViewModelValidator.cs @@ -1,4 +1,5 @@ using ErsatzTV.Core.Domain; +using ErsatzTV.FFmpeg.State; using ErsatzTV.ViewModels; using FluentValidation; @@ -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) diff --git a/ErsatzTV/ViewModels/WatermarkEditViewModel.cs b/ErsatzTV/ViewModels/WatermarkEditViewModel.cs index 409902f1a..470f83865 100644 --- a/ErsatzTV/ViewModels/WatermarkEditViewModel.cs +++ b/ErsatzTV/ViewModels/WatermarkEditViewModel.cs @@ -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 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; }