Stream custom live channels using your own media
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.
 
 
 

1255 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 ILocalFileSystem _localFileSystem;
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,
ILocalFileSystem localFileSystem,
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;
_localFileSystem = localFileSystem;
_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,
TimeSpan originalContentDuration,
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<FrameRate> 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
},
playbackSettings.TargetLoudness);
// 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,
new FrameRate(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<FrameRate>.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);
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,
InfiniteLoop: false,
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);
FrameRate frameRate = await playbackSettings.FrameRate
.IfNoneAsync(new FrameRate(videoVersion.MediaVersion.RFrameRate));
var context = new GraphicsEngineContext(
channel.Number,
audioVersion.MediaItem,
graphicsElementContexts,
TemplateVariables: [],
new Resolution { Width = targetSize.Width, Height = targetSize.Height },
channel.FFmpegProfile.Resolution,
frameRate,
channelStartTime,
start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
finish - now,
originalContentDuration);
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.Number == ".troubleshooting");
_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, videoVersion.MediaItem.Id);
}
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,
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<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 = _localFileSystem.GetCustomOrDefaultFile(
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);
_logger.LogDebug("HW accel mode: {HwAccel}", hwAccel);
var ffmpegState = new FFmpegState(
channel.Number == ".troubleshooting",
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.Number == ".troubleshooting");
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),
channel.Number == ".troubleshooting"
? 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<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) =>
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;
}
}