Files
ersatztv/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
T
timothy 66448e1abf
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 13s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 13s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 14s
Build CI Toolchain Image / Build & push CI image (push) Successful in 1m39s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 16s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m56s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 17m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(502): correct the deco-scoping claim, extract + test the routing guard
Adversarial review found a documentation defect, not a code one: both
docs/decisions.md and the WatermarkSelector comment asserted the deco path
was unaffected by this change. That is true of the *resolution* half and
false of the *routing* half. SelectWatermarks puts deco-derived options
into the same list the routing guard filters, so a deco watermark whose
resolved path is a URL is rerouted to the graphics engine too — including
the generated-initials localhost URL, which only the deco path still emits
and which plausibly rendered through ffmpeg before.

That reroute is intended (routing by what the path is beats routing by
provenance, which would drift), so the fix is to say so accurately rather
than to narrow the guard. Also records the accepted per-frame cost
asymmetry the entry previously argued on correctness grounds alone.

The guard is extracted as CanUseFFmpegNativeWatermark so it can be tested
directly — review's highest-value gap was that the half of the fix which
decides whether pixels appear had no automated coverage, only the one-off
live E2E. Nine cases pin it, including the localhost-fallback reroute.

Both deferrals now point at real issues instead of an unverifiable
"tracked separately": #510 (deco vs precedence-level missing-logo policy)
and #511 (remote-fetch hardening — timeout, size cap, redirects, pooling,
caching, SSRF).

Also pins scheme-case insensitivity in the selector.
2026-07-20 23:00:27 +02:00

1475 lines
58 KiB
C#

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;
}
/// <summary>
/// Whether ffmpeg can carry the watermark itself (a single permanent watermark and no other graphics),
/// rather than handing it to the graphics engine.
/// </summary>
/// <remarks>
/// A watermark whose resolved path is a remote URL is always refused here (#502). ffmpeg would receive it
/// as a bare <c>-i</c> argument — and <c>ffprobe</c> the same string for animation detection — putting an
/// unbounded network fetch inside stream startup, with no timeout, redirect or auth handling of ours, and
/// with the image's real dimensions never probed. The graphics engine fetches remote images deliberately
/// (<c>ImageElementBase.LoadImage</c>) and decodes them for their true size.
/// <para>
/// This is decided by the resolved <see cref="WatermarkOptions.ImagePath" /> alone, so it applies
/// uniformly however the watermark was selected — channel, global, playout item <em>or</em> deco. Only
/// `ChannelLogo` watermarks can produce a URL: `Custom` resolves through the image cache and
/// `Resource` through the resources folder, so neither is ever rerouted.
/// </para>
/// </remarks>
internal static bool CanUseFFmpegNativeWatermark(int graphicsElementCount, List<WatermarkOptions> watermarks) =>
graphicsElementCount == 0
&& watermarks.Count == 1
&& watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent
&& !Artwork.IsExternalUrl(wm.ImagePath));
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);
}
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
// QSV may have sync issues with h264 files that have multiple profiles
// check and flag here so software decoding can be used if needed
bool hasMultipleProfiles = false;
if (hwAccel is HardwareAccelerationMode.Qsv && videoStream.Codec is VideoFormat.H264)
{
hasMultipleProfiles = await ProbeHasMultipleProfiles(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)
{
HasMultipleProfiles = hasMultipleProfiles
};
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 = [];
if (CanUseFFmpegNativeWatermark(graphicsElements.Count, watermarks))
{
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)));
}
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,
channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
? FFmpegFilterMode.HardwareIfPossible
: FFmpegFilterMode.Software,
IsAnamorphic: false,
playbackSettings.FrameRate,
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale,
playbackSettings.NormalizeColors,
playbackSettings.Deinterlace);
// only use graphics engine when we have elements, and are normalizing video
if (videoFormat != VideoFormat.Copy && (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,
now > start ? now - start : 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 == FileSystemLayout.TranscodeTroubleshootingChannel,
channel.FFmpegProfile.QsvPreferNativeDecoder != false);
_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,
cancellationToken);
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;
}
private async Task<bool> ProbeHasMultipleProfiles(
string ffmpegPath,
MediaItem mediaItem,
CancellationToken cancellationToken)
{
_logger.LogDebug("Will probe for h264 profile count");
Option<int> profileCount =
await _localStatisticsProvider.GetProfileCount(ffmpegPath, mediaItem, cancellationToken);
return await profileCount.IfNoneAsync(1) > 1;
}
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,
CancellationToken cancellationToken)
{
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
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,
channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
? FFmpegFilterMode.HardwareIfPossible
: FFmpegFilterMode.Software,
IsAnamorphic: false,
playbackSettings.FrameRate,
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale,
playbackSettings.NormalizeColors,
playbackSettings.Deinterlace);
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 hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
var ffmpegState = new FFmpegState(
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
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,
hlsOptions.OutputFormat,
hlsOptions.HlsPlaylistPath,
hlsOptions.HlsSegmentTemplate,
hlsOptions.HlsInitTemplate,
hlsOptions.HlsSegmentOptions,
ptsOffset,
Option<int>.None,
qsvExtraHardwareFrames,
false,
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
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 == FileSystemLayout.TranscodeTroubleshootingChannel
? FileSystemLayout.TranscodeTroubleshootingFolder
: FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline);
}
public async Task<Command> Slug(
string ffmpegPath,
Channel channel,
DateTimeOffset now,
TimeSpan duration,
bool hlsRealtime,
TimeSpan ptsOffset,
CancellationToken cancellationToken)
{
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
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<FrameSize>.None,
channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
? FFmpegFilterMode.HardwareIfPossible
: FFmpegFilterMode.Software,
IsAnamorphic: false,
Option<FrameRate>.None,
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale,
playbackSettings.NormalizeColors,
playbackSettings.Deinterlace);
var frameRate = await playbackSettings.FrameRate.IfNoneAsync(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,
desiredState.PaddedSize,
MaybeSampleAspectRatio: "1:1",
DisplayAspectRatio: string.Empty,
frameRate,
true,
ScanKind.Progressive);
var videoInputFile = new LavfiInputFile(
$"color=c=black:s={desiredState.PaddedSize.Width}x{desiredState.PaddedSize.Height}:r={frameRate.FrameRateString}:d={duration.TotalSeconds}",
ffmpegVideoStream);
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
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,
hlsOptions.OutputFormat,
hlsOptions.HlsPlaylistPath,
hlsOptions.HlsSegmentTemplate,
hlsOptions.HlsInitTemplate,
hlsOptions.HlsSegmentOptions,
ptsOffset,
Option<int>.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<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
Option<ConcatInputFile>.None,
Option<GraphicsEngineInput>.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,
cancellationToken);
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,
CancellationToken cancellationToken)
{
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,
cancellationToken);
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,
cancellationToken);
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,
CancellationToken cancellationToken)
{
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,
cancellationToken);
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,
CancellationToken cancellationToken)
{
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,
cancellationToken);
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;
}
private static HlsOptions GetHlsOptions(Channel channel, DateTimeOffset now, TimeSpan ptsOffset, string audioFormat)
{
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}";
}
}
return new HlsOptions(outputFormat, hlsPlaylistPath, hlsSegmentTemplate, hlsInitTemplate, hlsSegmentOptions);
}
private sealed record HlsOptions(
OutputFormatKind OutputFormat,
Option<string> HlsPlaylistPath,
Option<string> HlsSegmentTemplate,
Option<string> HlsInitTemplate,
Option<string> HlsSegmentOptions);
}