diff --git a/ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs index e8eea5fa8..f53b2e948 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetSlugProcessByChannelNumberHandler.cs @@ -1,22 +1,15 @@ -using System.IO.Abstractions; using ErsatzTV.Core; using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Domain.Filler; -using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; -using ErsatzTV.Core.Interfaces.Metadata; -using ErsatzTV.FFmpeg; +using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Infrastructure.Data; -using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.Streaming; public class GetSlugProcessByChannelNumberHandler( IDbContextFactory dbContextFactory, - IFileSystem fileSystem, - IFFmpegProcessService ffmpegProcessService, - ILocalStatisticsProvider localStatisticsProvider) + IFFmpegProcessService ffmpegProcessService) : FFmpegProcessHandler(dbContextFactory) { protected override async Task> GetProcess( @@ -27,82 +20,30 @@ public class GetSlugProcessByChannelNumberHandler( string ffprobePath, CancellationToken cancellationToken) { - string videoPath = fileSystem.Path.Combine(FileSystemLayout.ResourcesCacheFolder, "slug.mp4"); - - bool saveReports = await dbContext.ConfigElements - .GetValue(ConfigElementKey.FFmpegSaveReports, cancellationToken) - .Map(result => result.IfNone(false)); - - Either maybeVersion = - await localStatisticsProvider.GetStatistics(ffprobePath, videoPath); - foreach (var error in maybeVersion.LeftToSeq()) - { - return error; - } - - var version = maybeVersion.RightToSeq().Head(); - - var mediaItem = new OtherVideo - { - MediaVersions = [version] - }; - - TimeSpan duration = version.Duration; - foreach (double slugSeconds in request.SlugSeconds) + var duration = TimeSpan.FromSeconds(await request.SlugSeconds.IfNoneAsync(0)); + if (duration <= TimeSpan.Zero) { - TimeSpan seconds = TimeSpan.FromSeconds(slugSeconds); - if (seconds > TimeSpan.Zero && seconds < duration) - { - duration = seconds; - } + return BaseError.New("Slug seconds must be non-zero"); } DateTimeOffset finish = request.Now.Add(duration); - PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem( + var playoutItemResult = await ffmpegProcessService.Slug( ffmpegPath, - ffprobePath, - saveReports, channel, - new MediaItemVideoVersion(mediaItem, version), - new MediaItemAudioVersion(mediaItem, version), - videoPath, - videoPath, - _ => Task.FromResult>([]), - string.Empty, - string.Empty, - string.Empty, - ChannelSubtitleMode.None, - request.Now, - finish, request.Now, duration, - [], - [], - channel.FFmpegProfile.VaapiDisplay, - channel.FFmpegProfile.VaapiDriver, - channel.FFmpegProfile.VaapiDevice, - Optional(channel.FFmpegProfile.QsvExtraHardwareFrames), request.HlsRealtime, - StreamInputKind.Vod, - FillerKind.None, - inPoint: TimeSpan.Zero, - request.ChannelStartTime, - request.PtsOffset, - request.TargetFramerate, - Option.None, - _ => { }, - canProxy: true, - cancellationToken); + request.PtsOffset); var result = new PlayoutItemProcessModel( - playoutItemResult.Process, - playoutItemResult.GraphicsEngineContext, + playoutItemResult, + Option.None, duration, finish, isComplete: true, request.Now.ToUnixTimeSeconds(), - playoutItemResult.MediaItemId, + Option.None, Optional(channel.PlayoutOffset), !request.HlsRealtime); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index cbb8d1193..efd9f7c5f 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -862,6 +862,191 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline); } + public async Task Slug( + string ffmpegPath, + Channel channel, + DateTimeOffset now, + TimeSpan duration, + bool hlsRealtime, + TimeSpan ptsOffset) + { + FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateErrorSettings( + channel.StreamingMode, + channel.FFmpegProfile, + hlsRealtime); + + Resolution desiredResolution = channel.FFmpegProfile.Resolution; + + string audioFormat = playbackSettings.AudioFormat switch + { + FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3, + FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm, + _ => AudioFormat.Aac + }; + + var audioState = new AudioState( + audioFormat, + playbackSettings.AudioChannels, + playbackSettings.AudioBitrate, + playbackSettings.AudioBufferSize, + playbackSettings.AudioSampleRate, + false, + AudioFilter.None, + playbackSettings.TargetLoudness); + + string videoFormat = GetVideoFormat(playbackSettings); + + var desiredState = new FrameState( + playbackSettings.RealtimeOutput, + InfiniteLoop: false, + videoFormat, + GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile), + VideoPreset.Unset, + channel.FFmpegProfile.AllowBFrames, + new PixelFormatYuv420P(), + new FrameSize(desiredResolution.Width, desiredResolution.Height), + new FrameSize(desiredResolution.Width, desiredResolution.Height), + Option.None, + channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible + ? FFmpegFilterMode.HardwareIfPossible + : FFmpegFilterMode.Software, + IsAnamorphic: false, + playbackSettings.FrameRate, + playbackSettings.VideoBitrate, + playbackSettings.VideoBufferSize, + playbackSettings.VideoTrackTimeScale, + playbackSettings.NormalizeColors, + playbackSettings.Deinterlace); + + OutputFormatKind outputFormat = OutputFormatKind.MpegTs; + switch (channel.StreamingMode) + { + case StreamingMode.HttpLiveStreamingSegmenter: + outputFormat = OutputFormatKind.Hls; + break; + } + + Option hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4 + ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8") + : Option.None; + + long nowSeconds = now.ToUnixTimeSeconds(); + + Option hlsSegmentTemplate = outputFormat switch + { + OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"), + OutputFormatKind.HlsMp4 => Path.Combine( + FileSystemLayout.TranscodeFolder, + channel.Number, + $"live_{nowSeconds}_%06d.m4s"), + _ => Option.None + }; + + Option hlsInitTemplate = outputFormat switch + { + OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4", + _ => Option.None + }; + + Option hlsSegmentOptions = Option.None; + if (outputFormat is OutputFormatKind.Hls) + { + string options = string.Empty; + + if (ptsOffset == TimeSpan.Zero) + { + options += "+initial_discontinuity"; + } + + if (audioFormat == AudioFormat.AacLatm) + { + options += "+latm"; + } + + if (!string.IsNullOrWhiteSpace(options)) + { + hlsSegmentOptions = $"mpegts_flags={options}"; + } + } + + string videoPath = _localFileSystem.GetCustomOrDefaultFile( + FileSystemLayout.ResourcesCacheFolder, + "background.png"); + + var videoVersion = BackgroundImageMediaVersion.ForPath(videoPath, desiredResolution); + + var frameRate = playbackSettings.FrameRate.IfNone(new FrameRate("24")); + + var ffmpegVideoStream = new VideoStream( + 0, + VideoFormat.GeneratedImage, + string.Empty, + new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p + ColorParams.Default, + new FrameSize(videoVersion.Width, videoVersion.Height), + videoVersion.SampleAspectRatio, + videoVersion.DisplayAspectRatio, + frameRate, + true, + ScanKind.Progressive); + + var videoInputFile = new LavfiInputFile( + $"color=c=black:s={videoVersion.Width}x{videoVersion.Height}:r={frameRate.FrameRateString}:d={duration.TotalSeconds}", + ffmpegVideoStream); + + HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings); + + var ffmpegState = new FFmpegState( + channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel, + HardwareAccelerationMode.None, // no hw accel decode since errors loop + hwAccel, + VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver), + VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice), + playbackSettings.StreamSeek, + duration, + channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect, + "ErsatzTV", + channel.Name, + None, + None, + None, + outputFormat, + hlsPlaylistPath, + hlsSegmentTemplate, + hlsInitTemplate, + hlsSegmentOptions, + ptsOffset, + Option.None, + Optional(channel.FFmpegProfile.QsvExtraHardwareFrames), + false, + false, + GetTonemapAlgorithm(playbackSettings), + channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel); + + var audioInputFile = new NullAudioInputFile(audioState); + + IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( + hwAccel, + videoInputFile, + audioInputFile, + Option.None, + Option.None, + Option.None, + Option.None, + VaapiDisplayName(hwAccel, channel.FFmpegProfile.VaapiDisplay), + VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver), + VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice), + channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel + ? FileSystemLayout.TranscodeTroubleshootingFolder + : FileSystemLayout.FFmpegReportsFolder, + FileSystemLayout.FontsCacheFolder, + ffmpegPath); + + FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState); + + return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline); + } + public async Task ConcatChannel( string ffmpegPath, bool saveReports, diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index 79f3ac935..1a4114869 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -58,6 +58,14 @@ public interface IFFmpegProcessService string vaapiDevice, Option qsvExtraHardwareFrames); + Task Slug( + string ffmpegPath, + Channel channel, + DateTimeOffset now, + TimeSpan duration, + bool hlsRealtime, + TimeSpan ptsOffset); + Task ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host); Task WrapSegmenter( diff --git a/ErsatzTV.FFmpeg/InputFile.cs b/ErsatzTV.FFmpeg/InputFile.cs index 930700d4b..51622e60c 100644 --- a/ErsatzTV.FFmpeg/InputFile.cs +++ b/ErsatzTV.FFmpeg/InputFile.cs @@ -60,7 +60,7 @@ public record NullAudioInputFile : AudioInputFile DesiredState) => InputOptions.Add(new LavfiInputOption()); - public void Deconstruct(out AudioState DesiredState) => DesiredState = this.DesiredState; + public void Deconstruct(out AudioState desiredState) => desiredState = DesiredState; } public record VideoInputFile( @@ -79,6 +79,26 @@ public record VideoInputFile( } } +public record LavfiInputFile : VideoInputFile +{ + public LavfiInputFile(string Parameters, VideoStream VideoStream) : base(Parameters, [VideoStream]) + { + this.Parameters = Parameters; + this.VideoStream = VideoStream; + + InputOptions.Add(new LavfiInputOption()); + } + + public string Parameters { get; init; } + public VideoStream VideoStream { get; init; } + + public void Deconstruct(out string parameters, out VideoStream videoStream) + { + parameters = Parameters; + videoStream = VideoStream; + } +} + public record WatermarkInputFile(string Path, IList VideoStreams, WatermarkState DesiredState) : VideoInputFile(Path, VideoStreams); diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 77ca0eba6..d13c75219 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -119,7 +119,6 @@ ResXFileCodeGenerator Common.Designer.cs - diff --git a/ErsatzTV/Resources/slug.mp4 b/ErsatzTV/Resources/slug.mp4 deleted file mode 100644 index 94a335138..000000000 Binary files a/ErsatzTV/Resources/slug.mp4 and /dev/null differ diff --git a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs index dcc035bb1..42f62beba 100644 --- a/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs +++ b/ErsatzTV/Services/RunOnce/ResourceExtractorService.cs @@ -27,7 +27,6 @@ public class ResourceExtractorService : BackgroundService await ExtractResource(assembly, "sequential-schedule.schema.json", stoppingToken); await ExtractResource(assembly, "sequential-schedule-import.schema.json", stoppingToken); await ExtractResource(assembly, "test.avs", stoppingToken); - await ExtractResource(assembly, "slug.mp4", stoppingToken); await ExtractFontResource(assembly, "Sen.ttf", stoppingToken); await ExtractFontResource(assembly, "Roboto-Regular.ttf", stoppingToken);