mirror of https://github.com/ErsatzTV/ErsatzTV.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1247 lines
48 KiB
1247 lines
48 KiB
using System.Collections.Immutable; |
|
using System.Text; |
|
using CliWrap; |
|
using CliWrap.Buffered; |
|
using ErsatzTV.Core.Domain; |
|
using ErsatzTV.Core.Domain.Filler; |
|
using ErsatzTV.Core.Extensions; |
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
|
using ErsatzTV.Core.Interfaces.Metadata; |
|
using ErsatzTV.Core.Interfaces.Repositories; |
|
using ErsatzTV.Core.Interfaces.Streaming; |
|
using ErsatzTV.FFmpeg; |
|
using ErsatzTV.FFmpeg.Environment; |
|
using ErsatzTV.FFmpeg.Format; |
|
using ErsatzTV.FFmpeg.OutputFormat; |
|
using ErsatzTV.FFmpeg.Pipeline; |
|
using ErsatzTV.FFmpeg.Preset; |
|
using ErsatzTV.FFmpeg.State; |
|
using Microsoft.Extensions.Caching.Memory; |
|
using Microsoft.Extensions.Logging; |
|
using MediaStream = ErsatzTV.Core.Domain.MediaStream; |
|
|
|
namespace ErsatzTV.Core.FFmpeg; |
|
|
|
public class FFmpegLibraryProcessService : IFFmpegProcessService |
|
{ |
|
private readonly IConfigElementRepository _configElementRepository; |
|
private readonly IGraphicsElementLoader _graphicsElementLoader; |
|
private readonly IMemoryCache _memoryCache; |
|
private readonly IMpegTsScriptService _mpegTsScriptService; |
|
private readonly ILocalStatisticsProvider _localStatisticsProvider; |
|
private readonly IMediaItemRepository _mediaItemRepository; |
|
private readonly ICustomStreamSelector _customStreamSelector; |
|
private readonly FFmpegProcessService _ffmpegProcessService; |
|
private readonly IFFmpegStreamSelector _ffmpegStreamSelector; |
|
private readonly ILogger<FFmpegLibraryProcessService> _logger; |
|
private readonly IPipelineBuilderFactory _pipelineBuilderFactory; |
|
private readonly ITempFilePool _tempFilePool; |
|
|
|
public FFmpegLibraryProcessService( |
|
FFmpegProcessService ffmpegProcessService, |
|
IFFmpegStreamSelector ffmpegStreamSelector, |
|
ICustomStreamSelector customStreamSelector, |
|
ITempFilePool tempFilePool, |
|
IPipelineBuilderFactory pipelineBuilderFactory, |
|
IConfigElementRepository configElementRepository, |
|
IGraphicsElementLoader graphicsElementLoader, |
|
IMemoryCache memoryCache, |
|
IMpegTsScriptService mpegTsScriptService, |
|
ILocalStatisticsProvider localStatisticsProvider, |
|
IMediaItemRepository mediaItemRepository, |
|
ILogger<FFmpegLibraryProcessService> logger) |
|
{ |
|
_ffmpegProcessService = ffmpegProcessService; |
|
_ffmpegStreamSelector = ffmpegStreamSelector; |
|
_customStreamSelector = customStreamSelector; |
|
_tempFilePool = tempFilePool; |
|
_pipelineBuilderFactory = pipelineBuilderFactory; |
|
_configElementRepository = configElementRepository; |
|
_graphicsElementLoader = graphicsElementLoader; |
|
_memoryCache = memoryCache; |
|
_mpegTsScriptService = mpegTsScriptService; |
|
_localStatisticsProvider = localStatisticsProvider; |
|
_mediaItemRepository = mediaItemRepository; |
|
_logger = logger; |
|
} |
|
|
|
public async Task<PlayoutItemResult> ForPlayoutItem( |
|
string ffmpegPath, |
|
string ffprobePath, |
|
bool saveReports, |
|
Channel channel, |
|
MediaItemVideoVersion videoVersion, |
|
MediaItemAudioVersion audioVersion, |
|
string videoPath, |
|
string audioPath, |
|
Func<FFmpegPlaybackSettings, Task<List<Subtitle>>> getSubtitles, |
|
string preferredAudioLanguage, |
|
string preferredAudioTitle, |
|
string preferredSubtitleLanguage, |
|
ChannelSubtitleMode subtitleMode, |
|
DateTimeOffset start, |
|
DateTimeOffset finish, |
|
DateTimeOffset now, |
|
List<WatermarkOptions> watermarks, |
|
List<PlayoutItemGraphicsElement> graphicsElements, |
|
string vaapiDisplay, |
|
VaapiDriver vaapiDriver, |
|
string vaapiDevice, |
|
Option<int> qsvExtraHardwareFrames, |
|
bool hlsRealtime, |
|
StreamInputKind streamInputKind, |
|
FillerKind fillerKind, |
|
TimeSpan inPoint, |
|
DateTimeOffset channelStartTime, |
|
TimeSpan ptsOffset, |
|
Option<int> targetFramerate, |
|
Option<string> customReportsFolder, |
|
Action<FFmpegPipeline> pipelineAction, |
|
bool canProxy, |
|
CancellationToken cancellationToken) |
|
{ |
|
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion.MediaVersion); |
|
|
|
// we cannot burst live input |
|
hlsRealtime = hlsRealtime || streamInputKind is StreamInputKind.Live; |
|
|
|
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateSettings( |
|
channel.StreamingMode, |
|
channel.FFmpegProfile, |
|
videoVersion.MediaVersion, |
|
videoStream, |
|
start, |
|
now, |
|
inPoint, |
|
hlsRealtime, |
|
streamInputKind, |
|
targetFramerate); |
|
|
|
List<Subtitle> allSubtitles = await getSubtitles(playbackSettings); |
|
|
|
Option<MediaStream> maybeAudioStream = Option<MediaStream>.None; |
|
Option<Subtitle> maybeSubtitle = Option<Subtitle>.None; |
|
|
|
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom) |
|
{ |
|
StreamSelectorResult result = await _customStreamSelector.SelectStreams( |
|
channel, |
|
start, |
|
audioVersion, |
|
allSubtitles); |
|
maybeAudioStream = result.AudioStream; |
|
maybeSubtitle = result.Subtitle; |
|
|
|
if (maybeAudioStream.IsNone) |
|
{ |
|
_logger.LogWarning( |
|
"No audio stream found using custom stream selector {StreamSelector}; will use default stream selection logic", |
|
channel.StreamSelector); |
|
} |
|
} |
|
|
|
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone) |
|
{ |
|
maybeAudioStream = |
|
await _ffmpegStreamSelector.SelectAudioStream( |
|
audioVersion, |
|
channel.StreamingMode, |
|
channel, |
|
preferredAudioLanguage, |
|
preferredAudioTitle, |
|
cancellationToken); |
|
|
|
maybeSubtitle = |
|
await _ffmpegStreamSelector.SelectSubtitleStream( |
|
allSubtitles.ToImmutableList(), |
|
channel, |
|
preferredSubtitleLanguage, |
|
subtitleMode, |
|
cancellationToken); |
|
} |
|
|
|
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Troubleshooting && maybeSubtitle.IsNone) |
|
{ |
|
maybeSubtitle = allSubtitles.HeadOrNone(); |
|
} |
|
|
|
if (canProxy) |
|
{ |
|
foreach (Subtitle subtitle in maybeSubtitle) |
|
{ |
|
if (subtitle.SubtitleKind == SubtitleKind.Sidecar || subtitle is |
|
{ SubtitleKind: SubtitleKind.Embedded, IsImage: false, IsExtracted: true }) |
|
{ |
|
// proxy to avoid dealing with escaping |
|
subtitle.Path = $"http://localhost:{Settings.StreamingPort}/media/subtitle/{subtitle.Id}"; |
|
|
|
foreach (TimeSpan seek in playbackSettings.StreamSeek) |
|
{ |
|
subtitle.Path += $"?seekToMs={(int)seek.TotalMilliseconds}"; |
|
} |
|
} |
|
} |
|
} |
|
|
|
string audioFormat = playbackSettings.AudioFormat switch |
|
{ |
|
FFmpegProfileAudioFormat.Aac => AudioFormat.Aac, |
|
FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm, |
|
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3, |
|
FFmpegProfileAudioFormat.Copy => AudioFormat.Copy, |
|
_ => throw new ArgumentOutOfRangeException($"unexpected audio format {playbackSettings.VideoFormat}") |
|
}; |
|
|
|
var audioState = new AudioState( |
|
audioFormat, |
|
playbackSettings.AudioChannels, |
|
playbackSettings.AudioBitrate, |
|
playbackSettings.AudioBufferSize, |
|
playbackSettings.AudioSampleRate, |
|
audioFormat != AudioFormat.Copy && videoPath == audioPath, |
|
playbackSettings.NormalizeLoudnessMode switch |
|
{ |
|
NormalizeLoudnessMode.LoudNorm => AudioFilter.LoudNorm, |
|
_ => AudioFilter.None |
|
}); |
|
|
|
// don't log generated images, or hls direct, which are expected to have unknown format |
|
bool isUnknownPixelFormatExpected = |
|
videoPath != audioPath || channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect; |
|
ILogger<FFmpegLibraryProcessService> pixelFormatLogger = isUnknownPixelFormatExpected ? null : _logger; |
|
|
|
IPixelFormat pixelFormat = await AvailablePixelFormats |
|
.ForPixelFormat(videoStream.PixelFormat, pixelFormatLogger) |
|
.IfNoneAsync(() => |
|
{ |
|
return videoStream.BitsPerRawSample switch |
|
{ |
|
8 => new PixelFormatYuv420P(), |
|
10 => new PixelFormatYuv420P10Le(), |
|
_ => new PixelFormatUnknown(videoStream.BitsPerRawSample) |
|
}; |
|
}); |
|
|
|
ScanKind scanKind = ScanKind.Progressive; |
|
if (playbackSettings.Deinterlace) |
|
{ |
|
scanKind = await ProbeScanKind(ffmpegPath, videoVersion.MediaItem, cancellationToken); |
|
} |
|
|
|
var ffmpegVideoStream = new VideoStream( |
|
videoStream.Index, |
|
videoStream.Codec, |
|
videoStream.Profile, |
|
Some(pixelFormat), |
|
new ColorParams( |
|
videoStream.ColorRange, |
|
videoStream.ColorSpace, |
|
videoStream.ColorTransfer, |
|
videoStream.ColorPrimaries), |
|
new FrameSize(videoVersion.MediaVersion.Width, videoVersion.MediaVersion.Height), |
|
videoVersion.MediaVersion.SampleAspectRatio, |
|
videoVersion.MediaVersion.DisplayAspectRatio, |
|
videoVersion.MediaVersion.RFrameRate, |
|
videoPath != audioPath, // still image when paths are different |
|
scanKind); |
|
|
|
var videoInputFile = new VideoInputFile( |
|
videoPath, |
|
new List<VideoStream> { ffmpegVideoStream }, |
|
streamInputKind); |
|
|
|
Option<AudioInputFile> audioInputFile = maybeAudioStream.Map(audioStream => |
|
{ |
|
var ffmpegAudioStream = new AudioStream(audioStream.Index, audioStream.Codec, audioStream.Channels); |
|
return new AudioInputFile(audioPath, new List<AudioStream> { ffmpegAudioStream }, audioState); |
|
}); |
|
|
|
// when no audio streams are available, use null audio source |
|
if (!audioVersion.MediaVersion.Streams.Any(s => s.MediaStreamKind is MediaStreamKind.Audio)) |
|
{ |
|
audioInputFile = new NullAudioInputFile(audioState with { PadAudio = playbackSettings.PadAudio }); |
|
} |
|
|
|
OutputFormatKind outputFormat = OutputFormatKind.MpegTs; |
|
switch (channel.StreamingMode) |
|
{ |
|
case StreamingMode.HttpLiveStreamingSegmenter: |
|
outputFormat = OutputFormatKind.Hls; |
|
break; |
|
case StreamingMode.HttpLiveStreamingDirect: |
|
{ |
|
// use mpeg-ts by default |
|
outputFormat = OutputFormatKind.MpegTs; |
|
|
|
// override with setting if applicable |
|
Option<OutputFormatKind> maybeOutputFormat = await _configElementRepository |
|
.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken); |
|
foreach (OutputFormatKind of in maybeOutputFormat) |
|
{ |
|
outputFormat = of; |
|
} |
|
|
|
break; |
|
} |
|
} |
|
|
|
Option<string> subtitleLanguage = Option<string>.None; |
|
Option<string> subtitleTitle = Option<string>.None; |
|
|
|
Option<SubtitleInputFile> subtitleInputFile = maybeSubtitle.Map<Option<SubtitleInputFile>>(subtitle => |
|
{ |
|
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && !subtitle.IsImage && |
|
subtitle.SubtitleKind == SubtitleKind.Embedded && |
|
(!subtitle.IsExtracted || string.IsNullOrWhiteSpace(subtitle.Path))) |
|
{ |
|
_logger.LogWarning("Subtitles are not yet available for this item"); |
|
return None; |
|
} |
|
|
|
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream( |
|
subtitle.IsImage ? subtitle.StreamIndex : 0, |
|
subtitle.Codec, |
|
StreamKind.Video); |
|
|
|
string subtitlePath = subtitle.Path; |
|
if (!canProxy && !subtitle.IsImage && subtitle.IsExtracted) |
|
{ |
|
subtitlePath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitlePath); |
|
} |
|
|
|
string path = subtitle.IsImage ? videoPath : subtitlePath; |
|
|
|
SubtitleMethod method = SubtitleMethod.Burn; |
|
if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect) |
|
{ |
|
method = (outputFormat, subtitle.SubtitleKind, subtitle.Codec) switch |
|
{ |
|
// mkv supports all subtitle codecs, maybe? |
|
(OutputFormatKind.Mkv, SubtitleKind.Embedded, _) => SubtitleMethod.Copy, |
|
|
|
// MP4 supports vobsub |
|
(OutputFormatKind.Mp4, SubtitleKind.Embedded, "dvdsub" or "dvd_subtitle" or "vobsub") => |
|
SubtitleMethod.Copy, |
|
|
|
// MP4 does not support PGS |
|
(OutputFormatKind.Mp4, SubtitleKind.Embedded, "pgs" or "pgssub" or "hdmv_pgs_subtitle") => |
|
SubtitleMethod.None, |
|
|
|
// ignore text subtitles for now |
|
_ => SubtitleMethod.None |
|
}; |
|
|
|
if (method == SubtitleMethod.None) |
|
{ |
|
return None; |
|
} |
|
|
|
// hls direct won't use extracted embedded subtitles |
|
if (subtitle.SubtitleKind == SubtitleKind.Embedded) |
|
{ |
|
path = videoPath; |
|
ffmpegSubtitleStream = ffmpegSubtitleStream with { Index = subtitle.StreamIndex }; |
|
} |
|
} |
|
|
|
if (method == SubtitleMethod.Copy) |
|
{ |
|
subtitleLanguage = Optional(subtitle.Language); |
|
subtitleTitle = Optional(subtitle.Title); |
|
} |
|
|
|
return new SubtitleInputFile( |
|
path, |
|
new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream }, |
|
method); |
|
}).Flatten(); |
|
|
|
Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None; |
|
Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None; |
|
Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None; |
|
List<GraphicsElementContext> graphicsElementContexts = []; |
|
|
|
// use ffmpeg for single permanent watermark, graphics engine for all others |
|
if (graphicsElements.Count == 0 && watermarks.Count == 1 && watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent)) |
|
{ |
|
foreach (var wm in watermarks) |
|
{ |
|
List<VideoStream> videoStreams = |
|
[ |
|
new( |
|
await wm.ImageStreamIndex.IfNoneAsync(0), |
|
"unknown", |
|
string.Empty, |
|
new PixelFormatUnknown(), |
|
ColorParams.Default, |
|
new FrameSize(1, 1), |
|
string.Empty, |
|
string.Empty, |
|
Option<string>.None, |
|
!await IsWatermarkAnimated(ffprobePath, wm.ImagePath), |
|
ScanKind.Progressive) |
|
]; |
|
|
|
var state = new WatermarkState( |
|
None, |
|
wm.Watermark.Location, |
|
wm.Watermark.Size, |
|
wm.Watermark.WidthPercent, |
|
wm.Watermark.HorizontalMarginPercent, |
|
wm.Watermark.VerticalMarginPercent, |
|
wm.Watermark.Opacity, |
|
wm.Watermark.PlaceWithinSourceContent); |
|
|
|
watermarkInputFile = new WatermarkInputFile(wm.ImagePath, videoStreams, state); |
|
} |
|
} |
|
else |
|
{ |
|
graphicsElementContexts.AddRange(watermarks.Map(wm => new WatermarkElementContext(wm))); |
|
} |
|
|
|
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, fillerKind); |
|
|
|
string videoFormat = GetVideoFormat(playbackSettings); |
|
Option<string> maybeVideoProfile = GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile); |
|
Option<string> maybeVideoPreset = GetVideoPreset( |
|
hwAccel, |
|
videoFormat, |
|
channel.FFmpegProfile.VideoPreset, |
|
FFmpegLibraryHelper.MapBitDepth(channel.FFmpegProfile.BitDepth)); |
|
|
|
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4 |
|
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8") |
|
: Option<string>.None; |
|
|
|
long nowSeconds = now.ToUnixTimeSeconds(); |
|
|
|
Option<string> 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<string>.None |
|
}; |
|
|
|
Option<string> hlsInitTemplate = outputFormat switch |
|
{ |
|
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4", |
|
_ => Option<string>.None |
|
}; |
|
|
|
Option<string> hlsSegmentOptions = Option<string>.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}"; |
|
} |
|
} |
|
|
|
FrameSize scaledSize = ffmpegVideoStream.SquarePixelFrameSize( |
|
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)); |
|
|
|
var paddedSize = new FrameSize( |
|
channel.FFmpegProfile.Resolution.Width, |
|
channel.FFmpegProfile.Resolution.Height); |
|
|
|
Option<FrameSize> cropSize = Option<FrameSize>.None; |
|
|
|
if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Stretch) |
|
{ |
|
scaledSize = paddedSize; |
|
} |
|
|
|
if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Crop) |
|
{ |
|
bool isTooSmallToCrop = videoVersion.MediaVersion.Height < channel.FFmpegProfile.Resolution.Height || |
|
videoVersion.MediaVersion.Width < channel.FFmpegProfile.Resolution.Width; |
|
|
|
// if any dimension is smaller than the crop, scale beyond the crop (beyond the target resolution) |
|
if (isTooSmallToCrop) |
|
{ |
|
foreach (IDisplaySize size in playbackSettings.ScaledSize) |
|
{ |
|
scaledSize = new FrameSize(size.Width, size.Height); |
|
} |
|
|
|
paddedSize = scaledSize; |
|
} |
|
else |
|
{ |
|
paddedSize = ffmpegVideoStream.SquarePixelFrameSizeForCrop( |
|
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)); |
|
} |
|
|
|
cropSize = new FrameSize( |
|
channel.FFmpegProfile.Resolution.Width, |
|
channel.FFmpegProfile.Resolution.Height); |
|
} |
|
|
|
var desiredState = new FrameState( |
|
playbackSettings.RealtimeOutput, |
|
fillerKind == FillerKind.Fallback, |
|
videoFormat, |
|
maybeVideoProfile, |
|
maybeVideoPreset, |
|
channel.FFmpegProfile.AllowBFrames, |
|
Optional(playbackSettings.PixelFormat), |
|
scaledSize, |
|
paddedSize, |
|
cropSize, |
|
false, |
|
playbackSettings.FrameRate, |
|
playbackSettings.VideoBitrate, |
|
playbackSettings.VideoBufferSize, |
|
playbackSettings.VideoTrackTimeScale, |
|
playbackSettings.Deinterlace); |
|
|
|
// only use graphics engine when we have elements |
|
if (graphicsElementContexts.Count > 0 || graphicsElements.Count > 0) |
|
{ |
|
FrameSize targetSize = await desiredState.CroppedSize.IfNoneAsync(desiredState.ScaledSize); |
|
|
|
var context = new GraphicsEngineContext( |
|
channel.Number, |
|
audioVersion.MediaItem, |
|
graphicsElementContexts, |
|
TemplateVariables: [], |
|
new Resolution { Width = targetSize.Width, Height = targetSize.Height }, |
|
channel.FFmpegProfile.Resolution, |
|
await playbackSettings.FrameRate.IfNoneAsync(24), |
|
channelStartTime, |
|
start, |
|
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero), |
|
finish - now); |
|
|
|
context = await _graphicsElementLoader.LoadAll(context, graphicsElements, cancellationToken); |
|
|
|
if (context.Elements.Count > 0) |
|
{ |
|
graphicsEngineInput = new GraphicsEngineInput(); |
|
graphicsEngineContext = context; |
|
} |
|
} |
|
|
|
var ffmpegState = new FFmpegState( |
|
saveReports, |
|
hwAccel, |
|
hwAccel, |
|
VaapiDriverName(hwAccel, vaapiDriver), |
|
VaapiDeviceName(hwAccel, vaapiDevice), |
|
playbackSettings.StreamSeek, |
|
finish - now, |
|
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect, |
|
"ErsatzTV", |
|
channel.Name, |
|
maybeAudioStream.Map(s => Optional(s.Language)).Flatten(), |
|
subtitleLanguage, |
|
subtitleTitle, |
|
outputFormat, |
|
hlsPlaylistPath, |
|
hlsSegmentTemplate, |
|
hlsInitTemplate, |
|
hlsSegmentOptions, |
|
ptsOffset, |
|
playbackSettings.ThreadCount, |
|
qsvExtraHardwareFrames, |
|
videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true }, |
|
false, |
|
GetTonemapAlgorithm(playbackSettings), |
|
channel.UniqueId == Guid.Empty); |
|
|
|
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState); |
|
|
|
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( |
|
hwAccel, |
|
videoInputFile, |
|
audioInputFile, |
|
watermarkInputFile, |
|
subtitleInputFile, |
|
Option<ConcatInputFile>.None, |
|
graphicsEngineInput, |
|
VaapiDisplayName(hwAccel, vaapiDisplay), |
|
VaapiDriverName(hwAccel, vaapiDriver), |
|
VaapiDeviceName(hwAccel, vaapiDevice), |
|
await customReportsFolder.IfNoneAsync(FileSystemLayout.FFmpegReportsFolder), |
|
FileSystemLayout.FontsCacheFolder, |
|
ffmpegPath); |
|
|
|
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState); |
|
|
|
pipelineAction?.Invoke(pipeline); |
|
|
|
Command command = GetCommand( |
|
ffmpegPath, |
|
videoInputFile, |
|
audioInputFile, |
|
watermarkInputFile, |
|
Option<ConcatInputFile>.None, |
|
graphicsEngineInput, |
|
pipeline); |
|
|
|
return new PlayoutItemResult(command, graphicsEngineContext); |
|
} |
|
|
|
private async Task<ScanKind> ProbeScanKind( |
|
string ffmpegPath, |
|
MediaItem mediaItem, |
|
CancellationToken cancellationToken) |
|
{ |
|
var headVersion = mediaItem.GetHeadVersion(); |
|
if (headVersion.VideoScanKind is VideoScanKind.Interlaced) |
|
{ |
|
_logger.LogDebug("Container is marked {ScanKind}", headVersion.VideoScanKind); |
|
return ScanKind.Interlaced; |
|
} |
|
|
|
// skip probe if disabled |
|
if (!await _configElementRepository.GetValue<bool>( |
|
ConfigElementKey.FFmpegProbeForInterlacedFrames, |
|
cancellationToken).IfNoneAsync(false)) |
|
{ |
|
_logger.LogDebug("Probe for interlaced frames is disabled"); |
|
return ScanKind.Progressive; |
|
} |
|
|
|
if (headVersion.InterlacedRatio is null) |
|
{ |
|
_logger.LogDebug("Will probe for interlaced frames"); |
|
|
|
Option<double> maybeInterlacedRatio = |
|
await _localStatisticsProvider.GetInterlacedRatio(ffmpegPath, mediaItem, cancellationToken); |
|
foreach (double ratio in maybeInterlacedRatio) |
|
{ |
|
await _mediaItemRepository.SetInterlacedRatio(mediaItem, ratio); |
|
} |
|
} |
|
|
|
var result = headVersion.InterlacedRatio > 0.05 ? ScanKind.Interlaced : ScanKind.Progressive; |
|
_logger.LogDebug( |
|
"Content has interlaced ratio of {Ratio} - will consider as {ScanKind}", |
|
headVersion.InterlacedRatio, |
|
result); |
|
return result; |
|
} |
|
|
|
public async Task<Command> ForError( |
|
string ffmpegPath, |
|
Channel channel, |
|
DateTimeOffset now, |
|
Option<TimeSpan> duration, |
|
string errorMessage, |
|
bool hlsRealtime, |
|
TimeSpan ptsOffset, |
|
string vaapiDisplay, |
|
VaapiDriver vaapiDriver, |
|
string vaapiDevice, |
|
Option<int> qsvExtraHardwareFrames) |
|
{ |
|
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateErrorSettings( |
|
channel.StreamingMode, |
|
channel.FFmpegProfile, |
|
hlsRealtime); |
|
|
|
Resolution desiredResolution = channel.FFmpegProfile.Resolution; |
|
|
|
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0); |
|
var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05); |
|
|
|
string subtitleFile = await new SubtitleBuilder(_tempFilePool) |
|
.WithResolution(desiredResolution) |
|
.WithFontName("Roboto") |
|
.WithFontSize(fontSize) |
|
.WithAlignment(2) |
|
.WithMarginV(margin) |
|
.WithPrimaryColor("&HFFFFFF") |
|
.WithFormattedContent(errorMessage.Replace(Environment.NewLine, "\\N")) |
|
.BuildFile(); |
|
|
|
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); |
|
|
|
string videoFormat = GetVideoFormat(playbackSettings); |
|
|
|
var desiredState = new FrameState( |
|
playbackSettings.RealtimeOutput, |
|
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<FrameSize>.None, |
|
false, |
|
playbackSettings.FrameRate, |
|
playbackSettings.VideoBitrate, |
|
playbackSettings.VideoBufferSize, |
|
playbackSettings.VideoTrackTimeScale, |
|
playbackSettings.Deinterlace); |
|
|
|
OutputFormatKind outputFormat = OutputFormatKind.MpegTs; |
|
switch (channel.StreamingMode) |
|
{ |
|
case StreamingMode.HttpLiveStreamingSegmenter: |
|
outputFormat = OutputFormatKind.Hls; |
|
break; |
|
} |
|
|
|
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4 |
|
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8") |
|
: Option<string>.None; |
|
|
|
long nowSeconds = now.ToUnixTimeSeconds(); |
|
|
|
Option<string> 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<string>.None |
|
}; |
|
|
|
Option<string> hlsInitTemplate = outputFormat switch |
|
{ |
|
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4", |
|
_ => Option<string>.None |
|
}; |
|
|
|
Option<string> hlsSegmentOptions = Option<string>.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 = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"); |
|
if (!File.Exists(videoPath)) |
|
{ |
|
videoPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "_background.png"); |
|
} |
|
|
|
var videoVersion = BackgroundImageMediaVersion.ForPath(videoPath, desiredResolution); |
|
|
|
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, |
|
None, |
|
true, |
|
ScanKind.Progressive); |
|
|
|
var videoInputFile = new VideoInputFile(videoPath, new List<VideoStream> { ffmpegVideoStream }); |
|
|
|
// TODO: ignore accel if this already failed once |
|
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings, FillerKind.None); |
|
_logger.LogDebug("HW accel mode: {HwAccel}", hwAccel); |
|
|
|
var ffmpegState = new FFmpegState( |
|
false, |
|
HardwareAccelerationMode.None, // no hw accel decode since errors loop |
|
hwAccel, |
|
VaapiDriverName(hwAccel, vaapiDriver), |
|
VaapiDeviceName(hwAccel, vaapiDevice), |
|
playbackSettings.StreamSeek, |
|
duration, |
|
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect, |
|
"ErsatzTV", |
|
channel.Name, |
|
None, |
|
None, |
|
None, |
|
outputFormat, |
|
hlsPlaylistPath, |
|
hlsSegmentTemplate, |
|
hlsInitTemplate, |
|
hlsSegmentOptions, |
|
ptsOffset, |
|
Option<int>.None, |
|
qsvExtraHardwareFrames, |
|
false, |
|
false, |
|
GetTonemapAlgorithm(playbackSettings), |
|
channel.UniqueId == Guid.Empty); |
|
|
|
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video); |
|
|
|
var audioInputFile = new NullAudioInputFile(audioState); |
|
|
|
var subtitleInputFile = new SubtitleInputFile( |
|
subtitleFile, |
|
new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream }, |
|
SubtitleMethod.Burn); |
|
|
|
_logger.LogDebug("FFmpeg desired error state {FrameState}", desiredState); |
|
|
|
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( |
|
hwAccel, |
|
videoInputFile, |
|
audioInputFile, |
|
Option<WatermarkInputFile>.None, |
|
subtitleInputFile, |
|
Option<ConcatInputFile>.None, |
|
Option<GraphicsEngineInput>.None, |
|
VaapiDisplayName(hwAccel, vaapiDisplay), |
|
VaapiDriverName(hwAccel, vaapiDriver), |
|
VaapiDeviceName(hwAccel, vaapiDevice), |
|
FileSystemLayout.FFmpegReportsFolder, |
|
FileSystemLayout.FontsCacheFolder, |
|
ffmpegPath); |
|
|
|
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState); |
|
|
|
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline); |
|
} |
|
|
|
public async Task<Command> ConcatChannel( |
|
string ffmpegPath, |
|
bool saveReports, |
|
Channel channel, |
|
string scheme, |
|
string host) |
|
{ |
|
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height); |
|
|
|
var concatInputFile = new ConcatInputFile( |
|
$"http://localhost:{Settings.StreamingPort}/ffmpeg/concat/{channel.Number}?mode=ts-legacy", |
|
resolution); |
|
|
|
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( |
|
HardwareAccelerationMode.None, |
|
Option<VideoInputFile>.None, |
|
Option<AudioInputFile>.None, |
|
Option<WatermarkInputFile>.None, |
|
Option<SubtitleInputFile>.None, |
|
concatInputFile, |
|
Option<GraphicsEngineInput>.None, |
|
Option<string>.None, |
|
Option<string>.None, |
|
Option<string>.None, |
|
FileSystemLayout.FFmpegReportsFolder, |
|
FileSystemLayout.FontsCacheFolder, |
|
ffmpegPath); |
|
|
|
FFmpegPipeline pipeline = pipelineBuilder.Concat( |
|
concatInputFile, |
|
FFmpegState.Concat(saveReports, channel.Name)); |
|
|
|
return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline); |
|
} |
|
|
|
public async Task<Command> WrapSegmenter( |
|
string ffmpegPath, |
|
bool saveReports, |
|
Channel channel, |
|
string scheme, |
|
string host, |
|
string accessToken, |
|
CancellationToken cancellationToken) |
|
{ |
|
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height); |
|
|
|
string accessTokenQuery = string.IsNullOrWhiteSpace(accessToken) |
|
? string.Empty |
|
: $"&access_token={accessToken}"; |
|
|
|
var concatInputFile = new ConcatInputFile( |
|
$"http://localhost:{Settings.StreamingPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter{accessTokenQuery}", |
|
resolution); |
|
|
|
if (channel.FFmpegProfile.AudioFormat is FFmpegProfileAudioFormat.AacLatm) |
|
{ |
|
concatInputFile.AudioFormat = AudioFormat.AacLatm; |
|
} |
|
|
|
// TODO: save reports? |
|
string defaultScript = await _configElementRepository |
|
.GetValue<string>(ConfigElementKey.FFmpegDefaultMpegTsScript, cancellationToken) |
|
.IfNoneAsync("Default"); |
|
List<MpegTsScript> allScripts = _mpegTsScriptService.GetScripts(); |
|
Option<MpegTsScript> maybeScript = Optional(allScripts.Find(s => s.Id == defaultScript)); |
|
foreach (var script in maybeScript) |
|
{ |
|
Option<Command> maybeCommand = await _mpegTsScriptService.Execute( |
|
script, |
|
channel, |
|
concatInputFile.Url, |
|
ffmpegPath); |
|
foreach (var command in maybeCommand) |
|
{ |
|
return command; |
|
} |
|
} |
|
|
|
if (maybeScript.IsNone) |
|
{ |
|
_logger.LogWarning("Unable to locate MPEG-TS Script in folder {Id}", defaultScript); |
|
} |
|
|
|
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( |
|
HardwareAccelerationMode.None, |
|
Option<VideoInputFile>.None, |
|
Option<AudioInputFile>.None, |
|
Option<WatermarkInputFile>.None, |
|
Option<SubtitleInputFile>.None, |
|
concatInputFile, |
|
Option<GraphicsEngineInput>.None, |
|
Option<string>.None, |
|
Option<string>.None, |
|
Option<string>.None, |
|
FileSystemLayout.FFmpegReportsFolder, |
|
FileSystemLayout.FontsCacheFolder, |
|
ffmpegPath); |
|
|
|
FFmpegPipeline pipeline = pipelineBuilder.WrapSegmenter( |
|
concatInputFile, |
|
FFmpegState.Concat(saveReports, channel.Name)); |
|
|
|
return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline); |
|
} |
|
|
|
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height) |
|
{ |
|
var videoInputFile = new VideoInputFile( |
|
inputFile, |
|
new List<VideoStream> |
|
{ |
|
new( |
|
0, |
|
string.Empty, |
|
string.Empty, |
|
None, |
|
ColorParams.Default, |
|
FrameSize.Unknown, |
|
string.Empty, |
|
string.Empty, |
|
None, |
|
true, |
|
ScanKind.Progressive) |
|
}); |
|
|
|
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( |
|
HardwareAccelerationMode.None, |
|
videoInputFile, |
|
Option<AudioInputFile>.None, |
|
Option<WatermarkInputFile>.None, |
|
Option<SubtitleInputFile>.None, |
|
Option<ConcatInputFile>.None, |
|
Option<GraphicsEngineInput>.None, |
|
Option<string>.None, |
|
Option<string>.None, |
|
Option<string>.None, |
|
FileSystemLayout.FFmpegReportsFolder, |
|
FileSystemLayout.FontsCacheFolder, |
|
ffmpegPath); |
|
|
|
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height)); |
|
|
|
return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false); |
|
} |
|
|
|
public Task<Either<BaseError, string>> GenerateSongImage( |
|
string ffmpegPath, |
|
string ffprobePath, |
|
Option<string> subtitleFile, |
|
Channel channel, |
|
MediaVersion videoVersion, |
|
string videoPath, |
|
bool boxBlur, |
|
Option<string> watermarkPath, |
|
WatermarkLocation watermarkLocation, |
|
int horizontalMarginPercent, |
|
int verticalMarginPercent, |
|
int watermarkWidthPercent, |
|
CancellationToken cancellationToken) => |
|
_ffmpegProcessService.GenerateSongImage( |
|
ffmpegPath, |
|
ffprobePath, |
|
subtitleFile, |
|
channel, |
|
videoVersion, |
|
videoPath, |
|
boxBlur, |
|
watermarkPath, |
|
watermarkLocation, |
|
horizontalMarginPercent, |
|
verticalMarginPercent, |
|
watermarkWidthPercent, |
|
cancellationToken); |
|
|
|
public async Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek) |
|
{ |
|
var videoInputFile = new VideoInputFile( |
|
inputFile, |
|
new List<VideoStream> |
|
{ |
|
new( |
|
0, |
|
codec, |
|
string.Empty, |
|
None, |
|
ColorParams.Default, |
|
FrameSize.Unknown, |
|
string.Empty, |
|
string.Empty, |
|
None, |
|
true, |
|
ScanKind.Progressive) |
|
}); |
|
|
|
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( |
|
HardwareAccelerationMode.None, |
|
videoInputFile, |
|
Option<AudioInputFile>.None, |
|
Option<WatermarkInputFile>.None, |
|
Option<SubtitleInputFile>.None, |
|
Option<ConcatInputFile>.None, |
|
Option<GraphicsEngineInput>.None, |
|
Option<string>.None, |
|
Option<string>.None, |
|
Option<string>.None, |
|
FileSystemLayout.FFmpegReportsFolder, |
|
FileSystemLayout.FontsCacheFolder, |
|
ffmpegPath); |
|
|
|
FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, codec, seek); |
|
|
|
return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false); |
|
} |
|
|
|
private Command GetCommand( |
|
string ffmpegPath, |
|
Option<VideoInputFile> videoInputFile, |
|
Option<AudioInputFile> audioInputFile, |
|
Option<WatermarkInputFile> watermarkInputFile, |
|
Option<ConcatInputFile> concatInputFile, |
|
Option<GraphicsEngineInput> graphicsEngineInput, |
|
FFmpegPipeline pipeline, |
|
bool log = true) |
|
{ |
|
IEnumerable<string> loggedSteps = pipeline.PipelineSteps.Map(ps => ps.GetType().Name); |
|
IEnumerable<string> loggedAudioFilters = |
|
audioInputFile.Map(f => f.FilterSteps.Map(af => af.GetType().Name)).Flatten(); |
|
IEnumerable<string> loggedVideoFilters = |
|
videoInputFile.Map(f => f.FilterSteps.Map(vf => vf.GetType().Name)).Flatten(); |
|
|
|
if (log) |
|
{ |
|
_logger.LogDebug( |
|
"FFmpeg pipeline {PipelineSteps}, {AudioFilters}, {VideoFilters}", |
|
loggedSteps, |
|
loggedAudioFilters, |
|
loggedVideoFilters |
|
); |
|
} |
|
|
|
IList<EnvironmentVariable> environmentVariables = |
|
CommandGenerator.GenerateEnvironmentVariables(pipeline.PipelineSteps); |
|
IList<string> arguments = CommandGenerator.GenerateArguments( |
|
videoInputFile, |
|
audioInputFile, |
|
watermarkInputFile, |
|
concatInputFile, |
|
graphicsEngineInput, |
|
pipeline.PipelineSteps, |
|
pipeline.IsIntelVaapiOrQsv); |
|
|
|
if (environmentVariables.Any()) |
|
{ |
|
_logger.LogDebug("FFmpeg environment variables {EnvVars}", environmentVariables); |
|
} |
|
|
|
return Cli.Wrap(ffmpegPath) |
|
.WithArguments(arguments) |
|
.WithValidation(CommandResultValidation.None) |
|
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null)) |
|
.WithEnvironmentVariables(environmentVariables.ToDictionary(e => e.Key, e => e.Value)); |
|
} |
|
|
|
private static Option<string> VaapiDisplayName(HardwareAccelerationMode accelerationMode, string vaapiDisplay) => |
|
accelerationMode == HardwareAccelerationMode.Vaapi ? vaapiDisplay : Option<string>.None; |
|
|
|
private static Option<string> VaapiDriverName(HardwareAccelerationMode accelerationMode, VaapiDriver driver) |
|
{ |
|
if (accelerationMode == HardwareAccelerationMode.Vaapi) |
|
{ |
|
switch (driver) |
|
{ |
|
case VaapiDriver.i965: |
|
return "i965"; |
|
case VaapiDriver.iHD: |
|
return "iHD"; |
|
case VaapiDriver.RadeonSI: |
|
return "radeonsi"; |
|
case VaapiDriver.Nouveau: |
|
return "nouveau"; |
|
} |
|
} |
|
|
|
return Option<string>.None; |
|
} |
|
|
|
private static Option<string> VaapiDeviceName(HardwareAccelerationMode accelerationMode, string vaapiDevice) => |
|
accelerationMode == HardwareAccelerationMode.Vaapi || |
|
OperatingSystem.IsLinux() && accelerationMode == HardwareAccelerationMode.Qsv |
|
? string.IsNullOrWhiteSpace(vaapiDevice) ? "/dev/dri/renderD128" : vaapiDevice |
|
: Option<string>.None; |
|
|
|
private static string GetVideoFormat(FFmpegPlaybackSettings playbackSettings) => |
|
playbackSettings.VideoFormat switch |
|
{ |
|
FFmpegProfileVideoFormat.Av1 => VideoFormat.Av1, |
|
FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc, |
|
FFmpegProfileVideoFormat.H264 => VideoFormat.H264, |
|
FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video, |
|
FFmpegProfileVideoFormat.Copy => VideoFormat.Copy, |
|
_ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}") |
|
}; |
|
|
|
private static string GetTonemapAlgorithm(FFmpegPlaybackSettings playbackSettings) => |
|
playbackSettings.TonemapAlgorithm switch |
|
{ |
|
FFmpegProfileTonemapAlgorithm.Linear => TonemapAlgorithm.Linear, |
|
FFmpegProfileTonemapAlgorithm.Clip => TonemapAlgorithm.Clip, |
|
FFmpegProfileTonemapAlgorithm.Gamma => TonemapAlgorithm.Gamma, |
|
FFmpegProfileTonemapAlgorithm.Reinhard => TonemapAlgorithm.Reinhard, |
|
FFmpegProfileTonemapAlgorithm.Mobius => TonemapAlgorithm.Mobius, |
|
FFmpegProfileTonemapAlgorithm.Hable => TonemapAlgorithm.Hable, |
|
_ => throw new ArgumentOutOfRangeException( |
|
$"unexpected tonemap algorithm {playbackSettings.TonemapAlgorithm}") |
|
}; |
|
|
|
private static Option<string> GetVideoProfile(string videoFormat, string videoProfile) => |
|
(videoFormat, (videoProfile ?? string.Empty).ToLowerInvariant()) switch |
|
{ |
|
(VideoFormat.H264, VideoProfile.Main) => VideoProfile.Main, |
|
(VideoFormat.H264, VideoProfile.High) => VideoProfile.High, |
|
(VideoFormat.H264, VideoProfile.High10) => VideoProfile.High10, |
|
(VideoFormat.H264, VideoProfile.High444p) => VideoProfile.High444p, |
|
_ => Option<string>.None |
|
}; |
|
|
|
private static Option<string> GetVideoPreset( |
|
HardwareAccelerationMode hardwareAccelerationMode, |
|
string videoFormat, |
|
string videoPreset, |
|
int bitDepth) => |
|
AvailablePresets |
|
.ForAccelAndFormat(hardwareAccelerationMode, videoFormat, bitDepth) |
|
.Find(p => string.Equals(p, videoPreset, StringComparison.OrdinalIgnoreCase)); |
|
|
|
private static HardwareAccelerationMode GetHardwareAccelerationMode( |
|
FFmpegPlaybackSettings playbackSettings, |
|
FillerKind fillerKind) => |
|
playbackSettings.HardwareAcceleration switch |
|
{ |
|
_ when fillerKind == FillerKind.Fallback => HardwareAccelerationMode.None, |
|
HardwareAccelerationKind.Nvenc => HardwareAccelerationMode.Nvenc, |
|
HardwareAccelerationKind.Qsv => HardwareAccelerationMode.Qsv, |
|
HardwareAccelerationKind.Vaapi => HardwareAccelerationMode.Vaapi, |
|
HardwareAccelerationKind.VideoToolbox => HardwareAccelerationMode.VideoToolbox, |
|
HardwareAccelerationKind.Amf => HardwareAccelerationMode.Amf, |
|
HardwareAccelerationKind.V4l2m2m => HardwareAccelerationMode.V4l2m2m, |
|
HardwareAccelerationKind.Rkmpp => HardwareAccelerationMode.Rkmpp, |
|
_ => HardwareAccelerationMode.None |
|
}; |
|
|
|
private async Task<bool> IsWatermarkAnimated(string ffprobePath, string path) |
|
{ |
|
try |
|
{ |
|
var cacheKey = $"image.animated.{Path.GetFileName(path)}"; |
|
if (_memoryCache.TryGetValue(cacheKey, out bool animated)) |
|
{ |
|
return animated; |
|
} |
|
|
|
BufferedCommandResult result = await Cli.Wrap(ffprobePath) |
|
.WithArguments( |
|
[ |
|
"-loglevel", "error", |
|
"-select_streams", "v:0", |
|
"-count_frames", |
|
"-show_entries", "stream=nb_read_frames", |
|
"-print_format", "csv", |
|
path |
|
]) |
|
.WithValidation(CommandResultValidation.None) |
|
.ExecuteBufferedAsync(Encoding.UTF8); |
|
|
|
if (result.ExitCode == 0) |
|
{ |
|
string output = result.StandardOutput; |
|
output = output.Replace("stream,", string.Empty); |
|
if (int.TryParse(output, out int frameCount)) |
|
{ |
|
bool isAnimated = frameCount > 1; |
|
_memoryCache.Set(cacheKey, isAnimated, TimeSpan.FromDays(1)); |
|
return isAnimated; |
|
} |
|
} |
|
else |
|
{ |
|
_logger.LogWarning( |
|
"Error checking frame count for file {File} exit code {ExitCode}", |
|
path, |
|
result.ExitCode); |
|
} |
|
} |
|
catch (Exception ex) |
|
{ |
|
_logger.LogWarning(ex, "Error checking frame count for file {File}", path); |
|
} |
|
|
|
return false; |
|
} |
|
}
|
|
|