emby-toolbox/EmbyToolbox/Services/FfmpegCommandBuilder.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

1218 lines
44 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed record FfmpegCommand(
string Executable,
IReadOnlyList<string> ArgumentList,
IReadOnlyList<SidecarFile> UsedExternalFiles,
IReadOnlyList<string> DisposableAttachmentDumpPaths,
IReadOnlyList<string> AppliedVideoFilters,
string? VideoEncoderForLog)
{
/// <summary>Только для логов и UI; не использовать для запуска процесса.</summary>
public string FullCommand => FormatCommandLineForDisplay(Executable, ArgumentList);
public static string FormatCommandLineForDisplay(string executable, IReadOnlyList<string> argumentList)
{
var parts = new List<string>(argumentList.Count + 1) { QuoteForDisplay(executable) };
parts.AddRange(argumentList.Select(QuoteForDisplay));
return string.Join(' ', parts);
}
private static string QuoteForDisplay(string arg)
{
if (arg.Length == 0)
{
return "\"\"";
}
var needsQuote = arg.AsSpan().ContainsAny(" \t\r\n\"");
if (!needsQuote)
{
return arg;
}
return '"' + arg.Replace("\"", "\\\"", StringComparison.Ordinal) + '"';
}
}
public sealed class FfmpegCommandBuilder
{
private const double VideoFpsEpsilon = 0.01;
public FfmpegCommand Build(
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
string outputPath,
VideoEncoderSettings? videoEncoder,
bool requiresVideoTranscode,
bool forceVideoTranscode = false)
{
var effectiveVideoTranscode = requiresVideoTranscode || forceVideoTranscode;
var ffmpegPath = Path.Combine(AppContext.BaseDirectory, "Tools", "ffmpeg.exe");
var ovr = item.TaskOverride;
var usedExternal = new List<SidecarFile>();
var disposableAttachmentDumps = new List<string>();
var supportsAttachmentsOut = SupportsAttachments(ovr.TargetContainer, profile.Container);
var embeddedMuxPlans = CollectEmbeddedAttachmentMuxPlans(item, supportsAttachmentsOut, outputPath, disposableAttachmentDumps);
var appliedVideoFilters = new List<string>();
string? encoderForLog = null;
var args = new List<string>
{
"-hide_banner",
"-y",
"-progress",
"pipe:1",
"-nostats"
};
var wantGenPts = NeedsGeneratedPts(item)
|| MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName);
if (wantGenPts)
{
args.AddRange(["-fflags", "+genpts"]);
}
if (embeddedMuxPlans is { Count: > 0 })
{
foreach (var p in embeddedMuxPlans.OrderBy(x => x.TypeOrdinal))
{
args.Add("-dump_attachment:t:" + p.TypeOrdinal);
args.Add(p.TempDumpFullPath);
}
}
args.Add("-i");
args.Add(item.FullPath);
var inputMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var nextInputIndex = 1;
foreach (var t in ovr.TrackOverrides.Where(t =>
t.Source == SourceKind.External &&
t.Action == TrackActionKind.Add &&
!string.IsNullOrWhiteSpace(t.ExternalPath) &&
t.StreamKind is not MediaStreamKind.Attachment))
{
var p = t.ExternalPath!;
if (!inputMap.ContainsKey(p))
{
inputMap[p] = nextInputIndex++;
args.Add("-i");
args.Add(p);
}
}
var effectiveContainer = EffectiveContainer(ovr, profile);
var media = item.MediaAnalysis;
var mapped = new List<MapEntry>();
foreach (var t in ovr.TrackOverrides)
{
if (t.Action == TrackActionKind.Remove)
{
continue;
}
if (t.Source == SourceKind.Embedded)
{
if (media is not null &&
t.StreamKind == MediaStreamKind.Subtitle &&
!SubtitleCodecRules.ShouldMapEmbeddedSubtitle(media, t, effectiveContainer))
{
continue;
}
if (t.StreamKind == MediaStreamKind.Attachment)
{
continue;
}
if (t.StreamIndex >= 0)
{
mapped.Add(new MapEntry(t));
}
}
else if (t.Source == SourceKind.External && t.Action == TrackActionKind.Add && !string.IsNullOrWhiteSpace(t.ExternalPath))
{
var ext = item.Sidecars.FirstOrDefault(s => string.Equals(s.FullPath, t.ExternalPath, StringComparison.OrdinalIgnoreCase));
if (ext is not null)
{
usedExternal.Add(ext);
}
if (t.StreamKind == MediaStreamKind.Attachment)
{
continue;
}
if (inputMap.TryGetValue(t.ExternalPath!, out var idx))
{
mapped.Add(new MapEntry(t));
}
}
}
string? muxFilterComplex420 = null;
var muxFilterComplexEmbeddedStreamIx = -1;
string muxFilterComplexOutLabel = "mbox_vf";
if (mapped.Count == 0)
{
args.Add("-map");
args.Add("0");
}
else
{
var mappedVideoOutputs =
mapped.Where(m => m.Track.StreamKind == MediaStreamKind.Video && m.Track.Action != TrackActionKind.Remove).ToList();
if (mappedVideoOutputs.Count == 1 &&
mappedVideoOutputs[0].Track is { StreamKind: MediaStreamKind.Video, Source: SourceKind.Embedded } ve &&
ve.StreamIndex >= 0)
{
var shouldMuxFc = ve.Action == TrackActionKind.Convert
|| (effectiveVideoTranscode && IsPrimaryVideoTrack(item, ve));
if (shouldMuxFc)
{
var encMux = videoEncoder ?? CreateCpuFallbackVideoEncoder(item, profile);
var mergedPre = MergeTargets(ovr, profile);
if (EncoderIsH264EightBitRestricted(encMux.Codec)
&& TryResolveEffectiveTargetPixelNormalized(mergedPre.PixelFormat, encMux.Codec, out var pnMux)
&& string.Equals(pnMux, "yuv420p", StringComparison.OrdinalIgnoreCase))
{
muxFilterComplexEmbeddedStreamIx = ve.StreamIndex;
var muxChain = BuildVideoFilterChain(item, profile, encMux.Codec).ToList();
if (!muxChain.Any(c => c.StartsWith("format=", StringComparison.OrdinalIgnoreCase)
|| c.Contains("format=yuv420p", StringComparison.OrdinalIgnoreCase)))
{
var muxStreamMeta = media?.AllStreams.FirstOrDefault(x =>
x.Index == muxFilterComplexEmbeddedStreamIx && x.Kind == MediaStreamKind.Video);
muxChain.Add(
ProbeSuggestsBt2020Gamut(muxStreamMeta)
? "colorspace=iall=bt2020:all=bt709:range=tv:format=yuv420p"
: "format=yuv420p");
}
var muxGraphBody = string.Join(",", muxChain);
muxFilterComplex420 = $"[0:{muxFilterComplexEmbeddedStreamIx}]{muxGraphBody}[{muxFilterComplexOutLabel}]";
appliedVideoFilters.Clear();
appliedVideoFilters.AddRange(muxChain);
}
}
}
if (muxFilterComplex420 is not null)
{
args.Add("-filter_complex");
args.Add(muxFilterComplex420);
}
foreach (var mm in mapped)
{
args.Add("-map");
var tr = mm.Track;
if (muxFilterComplexEmbeddedStreamIx >= 0
&& tr.StreamKind == MediaStreamKind.Video
&& tr.Source == SourceKind.Embedded
&& tr.StreamIndex == muxFilterComplexEmbeddedStreamIx
&& (tr.Action == TrackActionKind.Convert
|| (effectiveVideoTranscode && IsPrimaryVideoTrack(item, tr))))
{
args.Add('[' + muxFilterComplexOutLabel + ']');
}
else if (tr.Source == SourceKind.Embedded && tr.StreamIndex >= 0)
{
args.Add("0:" + tr.StreamIndex);
}
else if (tr.Source == SourceKind.External && !string.IsNullOrWhiteSpace(tr.ExternalPath))
{
if (inputMap.TryGetValue(tr.ExternalPath!, out var inIx))
{
if (tr.StreamKind == MediaStreamKind.Audio)
{
args.Add($"{inIx}:a:{tr.ExternalAudioStreamOrdinal}");
}
else
{
args.Add(inIx + ":0");
}
}
else
{
args.RemoveAt(args.Count - 1); // input index missing despite mapped list consistency
continue;
}
}
}
}
var mappedVideoOutputCount =
mapped.Count(m => m.Track.StreamKind == MediaStreamKind.Video && m.Track.Action != TrackActionKind.Remove);
if (mapped.Count == 0)
{
if (effectiveVideoTranscode)
{
var selectedVideoEncoder = videoEncoder ?? CreateCpuFallbackVideoEncoder(item, profile);
encoderForLog = selectedVideoEncoder.Codec;
args.Add("-c:v");
args.Add(selectedVideoEncoder.Codec);
AppendVideoTranscodeFiltersThenPixFmt(args, item, profile, appliedVideoFilters,
outVideoStreamIndex: null, selectedVideoEncoder.Codec,
mappedVideoOutputsForFilterBinding: 1, omitVfAndPixFmt: false);
AddExtraEncoderArgs(args, BuildVideoEncoderExtraArguments(item, profile, selectedVideoEncoder));
AppendVideoBitrateModeArgs(args, item, profile, 0);
}
else
{
args.Add("-c:v");
args.Add("copy");
}
args.Add("-c:a");
args.Add("copy");
}
else
{
var vIdx = 0;
var aIdx = 0;
var sIdx = 0;
foreach (var m in mapped)
{
switch (m.Track.StreamKind)
{
case MediaStreamKind.Video:
var shouldConvertVideo = m.Track.Action == TrackActionKind.Convert
|| (effectiveVideoTranscode && IsPrimaryVideoTrack(item, m.Track));
if (shouldConvertVideo)
{
var selectedVideoEncoder = videoEncoder ?? CreateCpuFallbackVideoEncoder(item, profile);
encoderForLog = selectedVideoEncoder.Codec;
args.Add("-c:v:" + vIdx);
args.Add(selectedVideoEncoder.Codec);
var omitVfAndPixFmt = muxFilterComplex420 is not null
&& muxFilterComplexEmbeddedStreamIx >= 0
&& shouldConvertVideo
&& m.Track.Source == SourceKind.Embedded
&& m.Track.StreamIndex == muxFilterComplexEmbeddedStreamIx;
AppendVideoTranscodeFiltersThenPixFmt(args, item, profile, appliedVideoFilters, vIdx,
selectedVideoEncoder.Codec, mappedVideoOutputCount,
omitVfAndPixFmt: omitVfAndPixFmt);
AddExtraEncoderArgs(args, BuildVideoEncoderExtraArguments(item, profile, selectedVideoEncoder));
AppendVideoBitrateModeArgs(args, item, profile, vIdx);
}
else
{
args.Add("-c:v:" + vIdx);
args.Add("copy");
}
vIdx++;
break;
case MediaStreamKind.Audio:
var transcodeAudio = m.Track.Action == TrackActionKind.Convert
|| ShouldTranscodeExternalAddedAudio(m.Track, profile);
if (transcodeAudio)
{
args.Add("-c:a:" + aIdx);
args.Add("aac");
var br = !string.IsNullOrWhiteSpace(m.Track.AudioBitrateKbps)
? m.Track.AudioBitrateKbps!
: (string.IsNullOrWhiteSpace(ovr.TargetAudioBitrate) ? profile.Bitrate : ovr.TargetAudioBitrate);
args.Add("-b:a:" + aIdx);
args.Add(ToFfmpegBitrate(br));
}
else
{
args.Add("-c:a:" + aIdx);
args.Add("copy");
}
ApplyLangAndDefault(args, "a", aIdx, m.Track);
aIdx++;
break;
case MediaStreamKind.Subtitle:
{
var subCodec = m.Track.Source == SourceKind.Embedded
? ResolveEmbeddedCodec(media, m.Track.StreamIndex)
: null;
var wantMp4 = IsMp4Container(ovr.TargetContainer, profile.Container);
var needMp4Text = wantMp4 &&
(m.Track.Source == SourceKind.External
|| m.Track.Action == TrackActionKind.Convert
|| SubtitleCodecRules.Mp4RequiresSubtitleTranscode(subCodec));
if (wantMp4 && needMp4Text)
{
args.Add("-c:s:" + sIdx);
args.Add("mov_text");
}
else
{
args.Add("-c:s:" + sIdx);
args.Add("copy");
}
ApplyLangAndDefault(args, "s", sIdx, m.Track);
sIdx++;
break;
}
}
}
}
if (supportsAttachmentsOut)
{
var fontIdx = 0;
foreach (var p in embeddedMuxPlans.OrderBy(x => x.TypeOrdinal))
{
args.Add("-attach");
args.Add(p.TempDumpFullPath);
if (!string.IsNullOrWhiteSpace(p.DeclaredFileNameForMetadata))
{
args.Add("-metadata:s:t:" + fontIdx);
args.Add("filename=" + p.DeclaredFileNameForMetadata.Trim());
}
args.Add("-metadata:s:t:" + fontIdx);
args.Add("mimetype=" + p.MimeType);
fontIdx++;
}
foreach (var t in ovr.TrackOverrides.Where(t => t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Attachment && t.Action == TrackActionKind.Add))
{
if (string.IsNullOrWhiteSpace(t.ExternalPath))
{
continue;
}
var ext = item.Sidecars.FirstOrDefault(s => string.Equals(s.FullPath, t.ExternalPath, StringComparison.OrdinalIgnoreCase));
if (ext is not null)
{
usedExternal.Add(ext);
}
var attachmentPath = t.ExternalPath!;
var displayName = Path.GetFileName(attachmentPath);
var extOnly = Path.GetExtension(displayName);
if (string.IsNullOrEmpty(extOnly))
{
extOnly = ".bin";
}
args.Add("-attach");
args.Add(attachmentPath);
args.Add("-metadata:s:t:" + fontIdx);
args.Add("filename=" + displayName);
args.Add("-metadata:s:t:" + fontIdx);
args.Add("mimetype=" + GuessFontAttachmentMime(extOnly));
fontIdx++;
}
}
if (MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName))
{
args.AddRange(["-avoid_negative_ts", "make_zero", "-muxdelay", "0", "-muxpreload", "0"]);
}
args.Add(outputPath);
return new FfmpegCommand(
ffmpegPath,
args,
usedExternal.DistinctBy(x => x.FullPath).ToList(),
disposableAttachmentDumps.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
appliedVideoFilters,
encoderForLog);
}
/// <summary>
/// Цепочка видеофильтров: fps → scale → format (совпадает с логикой плана транскодирования).
/// </summary>
public static IReadOnlyList<string> BuildVideoFilterChain(
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
string? videoEncoderCodec = null)
{
var merged = MergeTargets(item.TaskOverride, profile);
var v = item.MediaAnalysis?.PrimaryVideo;
var list = new List<string>(3);
var fpsClause = TryBuildFpsFilterClause(v, merged.Fps);
if (!string.IsNullOrEmpty(fpsClause))
{
list.Add(fpsClause);
}
var scaleClause = TryBuildScaleFilterClause(v, merged.Resolution);
if (!string.IsNullOrEmpty(scaleClause))
{
list.Add(scaleClause);
}
var encoderCodecGuess = videoEncoderCodec ?? string.Empty;
var formatClause =
TryBuildRestrictedH264Yuv420pBridgeClause(v, merged.PixelFormat, encoderCodecGuess)
?? TryBuildFormatFilterClause(v, merged.PixelFormat, encoderCodecGuess);
if (!string.IsNullOrEmpty(formatClause))
{
list.Add(formatClause);
}
return list;
}
/// <summary>Итоговый pix_fmt для фильтра/профиля: явный из UI или типичный для кодера (NVENC/x264).</summary>
public static bool TryGetEffectiveVideoOutputPixFmt(string mergedPixelFmtUi, string encoderCodec,
[NotNullWhen(true)] out string? pixNorm)
=> TryResolveEffectiveTargetPixelNormalized(mergedPixelFmtUi, encoderCodec, out pixNorm);
/// <summary>Нормализует pix_fmt из ffprobe (обрезка «(tv, …)»).</summary>
public static string? NormalizeFfprobePixelFormat(string? ffprobePix)
{
if (string.IsNullOrWhiteSpace(ffprobePix))
{
return null;
}
var t = ffprobePix.Trim();
var open = t.IndexOf('(', StringComparison.Ordinal);
if (open >= 0)
{
t = t[..open].Trim();
}
t = ToPixNorm(t);
return string.IsNullOrEmpty(t) ? null : t;
}
private static void AddExtraEncoderArgs(List<string> args, string? extra)
{
if (string.IsNullOrWhiteSpace(extra))
{
return;
}
foreach (var tok in extra.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
args.Add(tok);
}
}
/// <summary>
/// План переупаковки встроенного вложения: общий ffmpeg mux запрещает пакеты AVMEDIA_TYPE_ATTACHMENT
/// (см. libavformat mux.c → Received a packet for an attachment stream → EINVAL).
/// Снимаем вложение в файл (-dump_attachment) и подмешиваем через -attach.
/// </summary>
private readonly record struct EmbeddedAttachmentMuxPlan(
int TypeOrdinal,
string TempDumpFullPath,
string? DeclaredFileNameForMetadata,
string MimeType);
private static List<EmbeddedAttachmentMuxPlan> CollectEmbeddedAttachmentMuxPlans(
ConversionQueueItem item,
bool supportsAttachmentsOut,
string outputPath,
List<string> disposableAttachmentDumps)
{
var list = new List<EmbeddedAttachmentMuxPlan>();
if (!supportsAttachmentsOut || item.MediaAnalysis is not { } media)
{
return list;
}
var handledAttachmentStreams = new HashSet<int>();
foreach (var t in item.TaskOverride.TrackOverrides)
{
if (t.Action == TrackActionKind.Remove)
{
continue;
}
if (t.Source != SourceKind.Embedded || t.StreamKind != MediaStreamKind.Attachment || t.StreamIndex < 0)
{
continue;
}
if (!handledAttachmentStreams.Add(t.StreamIndex))
{
continue;
}
var typeOrd = AttachmentTypeOrdinal(media, t.StreamIndex);
if (typeOrd < 0)
{
continue;
}
var si = media.AllStreams.FirstOrDefault(s =>
s.Index == t.StreamIndex &&
s.Kind == MediaStreamKind.Attachment);
var declaredFnRaw = si?.AttachmentDeclaredFileName?.Trim();
var declaredFn = string.IsNullOrWhiteSpace(declaredFnRaw)
? null
: Path.GetFileName(declaredFnRaw);
var extFromDeclared = Path.GetExtension(declaredFn);
var ext = string.IsNullOrEmpty(extFromDeclared)
? ".bin"
: extFromDeclared;
var outDir = Path.GetDirectoryName(outputPath);
if (string.IsNullOrWhiteSpace(outDir))
{
continue;
}
Directory.CreateDirectory(outDir);
var dumpFileName = $"attachment_{typeOrd}{ext}";
var dumpPath = Path.Combine(outDir, dumpFileName);
disposableAttachmentDumps.Add(dumpPath);
var mime = !string.IsNullOrWhiteSpace(si?.AttachmentDeclaredMimeType)
? si.AttachmentDeclaredMimeType!.Trim()
: GuessFontAttachmentMime(ext);
list.Add(new EmbeddedAttachmentMuxPlan(typeOrd, dumpPath, declaredFn, mime));
}
return list;
}
/// <summary>Индекс вложения для -dump_attachment:t:N среди дорожек codec_type attachment (порядок по stream index).</summary>
private static int AttachmentTypeOrdinal(MediaAnalysisResult media, int attachmentStreamIndex)
{
var ordered = media.AllStreams
.Where(s => s.Kind == MediaStreamKind.Attachment)
.OrderBy(s => s.Index)
.ToList();
for (var i = 0; i < ordered.Count; i++)
{
if (ordered[i].Index == attachmentStreamIndex)
{
return i;
}
}
return -1;
}
private static string GuessFontAttachmentMime(string ext)
{
if (string.Equals(ext, ".ttf", StringComparison.OrdinalIgnoreCase) ||
string.Equals(ext, ".ttc", StringComparison.OrdinalIgnoreCase))
{
return "application/x-truetype-font";
}
if (string.Equals(ext, ".otf", StringComparison.OrdinalIgnoreCase))
{
return "application/font-sfnt";
}
return "application/octet-stream";
}
private static void ApplyLangAndDefault(List<string> args, string ffType, int outIndex, TrackOverrideEntry t)
{
if (!string.IsNullOrWhiteSpace(t.Language))
{
args.Add("-metadata:s:" + ffType + ':' + outIndex);
args.Add("language=" + t.Language!.Trim());
}
if (!string.IsNullOrWhiteSpace(t.Title))
{
args.Add("-metadata:s:" + ffType + ':' + outIndex);
args.Add("title=" + t.Title.Trim());
}
if (t.Default is true)
{
args.Add("-disposition:" + ffType + ':' + outIndex);
args.Add("default");
}
else if (t.Default is false)
{
args.Add("-disposition:" + ffType + ':' + outIndex);
args.Add("0");
}
}
private static bool SupportsAttachments(string? targetContainer, string profileContainer)
{
var c = string.IsNullOrWhiteSpace(targetContainer) ? profileContainer : targetContainer!;
return c.Contains("mkv", StringComparison.OrdinalIgnoreCase) || c.Contains("matro", StringComparison.OrdinalIgnoreCase);
}
private static bool IsMp4Container(string? targetContainer, string profileContainer)
{
var c = string.IsNullOrWhiteSpace(targetContainer) ? profileContainer : targetContainer!;
return c.Contains("mp4", StringComparison.OrdinalIgnoreCase) || c.Contains("mov", StringComparison.OrdinalIgnoreCase);
}
private static string ToFfmpegBitrate(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "256k";
}
var v = value.Trim().ToLowerInvariant().Replace(" ", string.Empty, StringComparison.Ordinal);
if (v.EndsWith("kbps", StringComparison.Ordinal))
{
v = v[..^4] + "k";
}
else if (v.EndsWith("k", StringComparison.Ordinal))
{
// already ffmpeg style
}
else if (int.TryParse(v, out _))
{
v += "k";
}
return v;
}
private readonly record struct MapEntry(TrackOverrideEntry Track);
public static VideoEncoderSettings CreateCpuFallbackVideoEncoder(ConversionQueueItem item, ConversionProfileSettingsEntry profile)
{
var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo;
var isH265 = targetVideo.Contains("265", StringComparison.OrdinalIgnoreCase)
|| targetVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase);
return isH265
? new VideoEncoderSettings("libx265", "-preset medium -crf 23")
: new VideoEncoderSettings("libx264", "-preset medium -crf 23");
}
/// <summary>Порядок как ожидает NVENC/libav: уже после —c:v— цепочка —vf/—filter:v— затем —pix_fmt— и опции кодера.</summary>
private static void AppendVideoTranscodeFiltersThenPixFmt(
List<string> args,
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
List<string> appliedVideoFiltersAccumulator,
int? outVideoStreamIndex,
string encoderCodec,
int mappedVideoOutputsForFilterBinding,
bool omitVfAndPixFmt = false)
{
if (omitVfAndPixFmt)
{
return;
}
var merged = MergeTargets(item.TaskOverride, profile);
var chain = BuildVideoFilterChain(item, profile, encoderCodec).ToList();
var havePix = TryResolveEffectiveTargetPixelNormalized(merged.PixelFormat, encoderCodec, out var pixNorm);
if (!havePix && EncoderIsH264EightBitRestricted(encoderCodec))
{
pixNorm = "yuv420p";
havePix = true;
}
if (havePix)
{
// Без единого format= в -filter и только с —pix_fmt libav вставляет auto_scale (10→10) и падает на Hi10.
AppendRequiredFormat420ForRestrictedH264IfPix420p(chain, pixNorm!, encoderCodec);
}
if (chain is { Count: > 0 })
{
if (appliedVideoFiltersAccumulator.Count == 0)
{
appliedVideoFiltersAccumulator.AddRange(chain);
}
AppendUnifiedVideoFilterArg(args, chain, outVideoStreamIndex, mappedVideoOutputsForFilterBinding);
}
if (havePix)
{
if (outVideoStreamIndex is { } idx)
{
args.Add("-pix_fmt:v:" + idx);
args.Add(pixNorm!);
}
else
{
args.Add("-pix_fmt");
args.Add(pixNorm!);
}
}
}
/// <summary>
/// Любой выход yuv420p через libx264 и аппаратные AVC-энкодеры (nvenc/qsv/amf…) при 10-бит входе требует явного format= в -filter.
/// </summary>
private static void AppendRequiredFormat420ForRestrictedH264IfPix420p(
List<string> chain,
string resolvedPixNorm,
string encoderCodec)
{
if (!EncoderIsH264EightBitRestricted(encoderCodec)
|| !string.Equals(resolvedPixNorm, "yuv420p", StringComparison.OrdinalIgnoreCase))
{
return;
}
foreach (var c in chain)
{
if (c.StartsWith("format=", StringComparison.OrdinalIgnoreCase)
|| c.Contains("format=yuv420p", StringComparison.OrdinalIgnoreCase))
{
return;
}
}
chain.Add("format=yuv420p");
}
private static bool TryResolveConcreteTargetPixelNormalized(string mergedPixelFmtUi, [NotNullWhen(true)] out string? pixNorm)
{
pixNorm = null;
if (!HasConcreteFfmpegPixelTargetString(mergedPixelFmtUi))
{
return false;
}
pixNorm = NormalizeFfprobePixelFormat(mergedPixelFmtUi.Trim());
return !string.IsNullOrWhiteSpace(pixNorm);
}
private static bool TryResolveEffectiveTargetPixelNormalized(string mergedPixelFmtUi, string encoderCodec,
[NotNullWhen(true)] out string? pixNorm)
{
pixNorm = null;
string? profilePx =
TryResolveConcreteTargetPixelNormalized(mergedPixelFmtUi, out var px) ? px : null;
// Цель из профиля «yuv420p10le» + h264_nvenc: без явного format= цепочка не даёт даунскейла,
// libav ставит auto_scale (10→10) и падает. Для AVC 8-бит ограничиваем выход до yuv420p.
if (profilePx is not null
&& IsTenBitFfPixelFormat(profilePx)
&& EncoderIsH264EightBitRestricted(encoderCodec))
{
pixNorm = "yuv420p";
return true;
}
if (profilePx is not null)
{
pixNorm = profilePx;
return true;
}
return TryInferDefaultPixFmtFromEncoder(encoderCodec, out pixNorm);
}
private static bool EncoderIsH264EightBitRestricted(string encoderCodec)
{
if (string.IsNullOrWhiteSpace(encoderCodec))
{
return false;
}
var lc = encoderCodec.Trim().ToLowerInvariant();
if (string.Equals(lc, "libx264", StringComparison.Ordinal))
{
return true;
}
// FFmpeg: h264_nvenc, h264_qsv, h264_amf, h264_vaapi, h264_mediacodec, ...
return lc.StartsWith("h264_", StringComparison.Ordinal);
}
private static bool IsTenBitFfPixelFormat(string normalizedPixFmt)
{
if (string.IsNullOrWhiteSpace(normalizedPixFmt))
{
return false;
}
var p = normalizedPixFmt.ToLowerInvariant();
return p.Contains("10le", StringComparison.Ordinal)
|| p.Contains("10be", StringComparison.Ordinal)
|| p.Contains("p10", StringComparison.Ordinal);
}
private static bool TryInferDefaultPixFmtFromEncoder(string encoderCodec,
[NotNullWhen(true)] out string? pixNorm)
{
pixNorm = null;
if (string.IsNullOrWhiteSpace(encoderCodec))
{
return false;
}
var lc = encoderCodec.Trim().ToLowerInvariant();
if (lc.Contains("nvenc", StringComparison.Ordinal)
|| lc.StartsWith("h264_", StringComparison.Ordinal))
{
pixNorm = "yuv420p";
return true;
}
if (string.Equals(lc, "libx264", StringComparison.Ordinal)
|| string.Equals(lc, "libx265", StringComparison.Ordinal))
{
pixNorm = "yuv420p";
return true;
}
return false;
}
private static bool HasConcreteFfmpegPixelTargetString(string? mergedPixelFmtUi) =>
!string.IsNullOrWhiteSpace(mergedPixelFmtUi)
&& !IsUiUnchanged(mergedPixelFmtUi)
&& !mergedPixelFmtUi.Contains("без", StringComparison.OrdinalIgnoreCase);
public static string ResolveMergedTargetPixelFormatUi(ConversionTaskOverride ovr, ConversionProfileSettingsEntry profile)
{
var merged = MergeTargets(ovr, profile);
return merged.PixelFormat;
}
private static void AppendUnifiedVideoFilterArg(
List<string> args,
IReadOnlyList<string> filters,
int? outVideoStreamIndex,
int mappedVideoOutputsForFilterBinding)
{
if (filters is not { Count: > 0 })
{
return;
}
var joined = string.Join(",", filters);
// Один видеовыход: -vf надёжнее связывает граф на некоторых сборках libav, чем -filter:v:0 при смешении с аудио.
var useSimpleVf = mappedVideoOutputsForFilterBinding <= 1 &&
(outVideoStreamIndex is null || outVideoStreamIndex.Value == 0);
if (useSimpleVf)
{
args.Add("-vf");
args.Add(joined);
}
else if (outVideoStreamIndex is { } i)
{
args.Add("-filter:v:" + i);
args.Add(joined);
}
else
{
args.Add("-vf");
args.Add(joined);
}
}
private static ConversionProfileSettingsEntry MergeTargets(
ConversionTaskOverride ovr,
ConversionProfileSettingsEntry profile)
{
static string Coa(string? a, string b) => string.IsNullOrWhiteSpace(a) ? b : a.Trim();
return profile with
{
Container = Coa(ovr.TargetContainer, profile.Container),
Video = Coa(ovr.TargetVideo, profile.Video),
PixelFormat = Coa(ovr.TargetPixelFormat, profile.PixelFormat),
Resolution = Coa(ovr.TargetResolution, profile.Resolution),
Fps = Coa(ovr.TargetFps, profile.Fps),
Bitrate = Coa(ovr.TargetAudioBitrate, profile.Bitrate),
VideoBitrateMode = Coa(ovr.TargetVideoBitrateMode, profile.VideoBitrateMode),
VideoBitrateMbps = ovr.TargetVideoBitrateMbps > 0 ? ovr.TargetVideoBitrateMbps : profile.VideoBitrateMbps
};
}
private static void AppendVideoBitrateModeArgs(
List<string> args,
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
int outVideoStreamIndex)
{
var merged = MergeTargets(item.TaskOverride, profile);
var targetKbps = VideoBitratePolicy.ResolveTargetKbps(
merged.VideoBitrateMode,
merged.VideoBitrateMbps,
item.MediaAnalysis);
if (targetKbps is not { } kbps || kbps <= 0)
{
return;
}
args.Add("-b:v:" + outVideoStreamIndex);
args.Add(kbps + "k");
args.Add("-maxrate:v:" + outVideoStreamIndex);
args.Add(kbps + "k");
args.Add("-bufsize:v:" + outVideoStreamIndex);
args.Add((kbps * 2) + "k");
}
private static string BuildVideoEncoderExtraArguments(
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
VideoEncoderSettings encoder)
{
var merged = MergeTargets(item.TaskOverride, profile);
var targetKbps = VideoBitratePolicy.ResolveTargetKbps(
merged.VideoBitrateMode,
merged.VideoBitrateMbps,
item.MediaAnalysis);
if (targetKbps is null)
{
return encoder.ExtraArguments;
}
return encoder.Codec.ToLowerInvariant() switch
{
"libx264" or "libx265" => "-preset medium",
"h264_nvenc" or "hevc_nvenc" => "-preset p5",
"h264_qsv" or "hevc_qsv" => string.Empty,
"h264_amf" or "hevc_amf" => "-quality quality",
_ => encoder.ExtraArguments
};
}
private static bool IsUiUnchanged(string? s) =>
string.IsNullOrWhiteSpace(s)
|| s.Contains("без", StringComparison.OrdinalIgnoreCase)
|| s.Contains("No change", StringComparison.OrdinalIgnoreCase);
private static string ToPixNorm(string p) => p.Replace(" ", string.Empty, StringComparison.Ordinal);
private static bool ProbeSuggestsBt2020Gamut(MediaStreamInfo? v)
{
if (v is null)
{
return false;
}
static bool Has2020(string? s) =>
!string.IsNullOrWhiteSpace(s) && s.Contains("2020", StringComparison.OrdinalIgnoreCase);
return Has2020(v.ColorSpace) || Has2020(v.ColorPrimaries) || Has2020(v.ColorTransfer);
}
/// <summary>
/// Hi10 yuv420p10le при цели yuv420p + AVC/NVENC: для разметки BT.2020 (частые BDRip) недостаточно format=yuv420p —
/// colorspace задаёт связный даунграйд без auto_scale по цвету.
/// </summary>
private static string? TryBuildRestrictedH264Yuv420pBridgeClause(
MediaStreamInfo? v,
string mergedPixelFmtUi,
string encoderCodec)
{
if (!EncoderIsH264EightBitRestricted(encoderCodec))
{
return null;
}
if (!TryResolveEffectiveTargetPixelNormalized(mergedPixelFmtUi, encoderCodec, out var targetNorm)
|| !string.Equals(targetNorm, "yuv420p", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var srcNorm = NormalizeFfprobePixelFormat(v?.PixelFormat);
if (string.IsNullOrEmpty(srcNorm))
{
return null;
}
if (string.Equals(srcNorm, "yuv420p", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (ProbeSuggestsBt2020Gamut(v))
{
return "colorspace=iall=bt2020:all=bt709:range=tv:format=yuv420p";
}
return "format=yuv420p";
}
private static string? TryBuildFormatFilterClause(MediaStreamInfo? v, string mergedPixelFmtUi, string encoderCodec)
{
if (v is null || !TryResolveEffectiveTargetPixelNormalized(mergedPixelFmtUi, encoderCodec, out var targetNorm))
{
return null;
}
var srcNorm = NormalizeFfprobePixelFormat(v.PixelFormat);
if (string.IsNullOrEmpty(srcNorm))
{
return null;
}
if (!string.Equals(srcNorm, targetNorm, StringComparison.OrdinalIgnoreCase))
{
return "format=" + targetNorm;
}
return null;
}
private static string? TryBuildFpsFilterClause(MediaStreamInfo? v, string mergedFpsUi)
{
if (v?.FrameRate is not { } f || f <= 0 || IsUiUnchanged(mergedFpsUi))
{
return null;
}
if (TryParseMaxNumericFromUiLabel(mergedFpsUi) is not { } cap || f <= cap + VideoFpsEpsilon)
{
return null;
}
var capStr = FormatFpsForFilter(cap);
return $"fps={capStr}";
}
private static string FormatFpsForFilter(double fps)
{
if (Math.Abs(fps % 1) < VideoFpsEpsilon)
{
return ((int)fps).ToString(CultureInfo.InvariantCulture);
}
return fps.ToString("0.###", CultureInfo.InvariantCulture);
}
private static string? TryBuildScaleFilterClause(MediaStreamInfo? v, string mergedResolutionUi)
{
if (v?.Width is not { } iw || v.Height is not { } ih || iw <= 0 || ih <= 0
|| IsUiUnchanged(mergedResolutionUi))
{
return null;
}
// «Максимум Np»: ограничиваем более короткую сторону (как в ConversionPlanService).
if (TryParseMaxNumericFromUiLabel(mergedResolutionUi) is { } shortSideCap)
{
var mn = Math.Min(iw, ih);
if (mn <= shortSideCap + VideoFpsEpsilon)
{
return null;
}
var maxEsc = shortSideCap.ToString(CultureInfo.InvariantCulture);
return iw >= ih
? $"scale=-2:min(ih\\,{maxEsc})"
: $"scale=min(iw\\,{maxEsc}):-2";
}
return null;
}
/// <summary>Из подписей UI («Максимум 1080p», «Максимум 60» fps) достаём числовой порог.</summary>
private static double? TryParseMaxNumericFromUiLabel(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
if (double.TryParse(raw.Replace(',', '.').Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
{
return d;
}
var parts = raw.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
for (var i = parts.Length - 1; i >= 0; i--)
{
var token = parts[i]
.Replace("p", string.Empty, StringComparison.OrdinalIgnoreCase)
.Replace("fps", string.Empty, StringComparison.OrdinalIgnoreCase)
.Trim();
if (double.TryParse(token.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out d))
{
return d;
}
}
return null;
}
private static bool IsPrimaryVideoTrack(ConversionQueueItem item, TrackOverrideEntry track)
{
if (track.Source != SourceKind.Embedded || track.StreamKind != MediaStreamKind.Video || track.StreamIndex < 0)
{
return false;
}
return item.MediaAnalysis?.PrimaryVideo?.Index == track.StreamIndex;
}
private static bool NeedsGeneratedPts(ConversionQueueItem item)
{
var sourceIsAvi = item.FileName.EndsWith(".avi", StringComparison.OrdinalIgnoreCase)
|| item.MediaAnalysis?.ContainerFormat?.Contains("avi", StringComparison.OrdinalIgnoreCase) is true
|| item.MediaAnalysis?.FormatName?.Contains("avi", StringComparison.OrdinalIgnoreCase) is true;
if (!sourceIsAvi)
{
return false;
}
var hasVideoCopy = item.TaskOverride.TrackOverrides.Any(t =>
t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Keep);
return hasVideoCopy;
}
private static string EffectiveContainer(ConversionTaskOverride ovr, ConversionProfileSettingsEntry profile) =>
string.IsNullOrWhiteSpace(ovr.TargetContainer) ? profile.Container : ovr.TargetContainer.Trim();
private static string? ResolveEmbeddedCodec(MediaAnalysisResult? analysis, int streamIndex)
{
if (analysis is null || streamIndex < 0)
{
return null;
}
return analysis.AllStreams.FirstOrDefault(s => s.Index == streamIndex)?.CodecName;
}
private static bool ShouldTranscodeExternalAddedAudio(TrackOverrideEntry track, ConversionProfileSettingsEntry profile)
{
if (track.Source != SourceKind.External
|| track.StreamKind != MediaStreamKind.Audio
|| track.Action != TrackActionKind.Add)
{
return false;
}
if (!TargetAudioRequiresAac(profile.Audio))
{
return false;
}
return !IsAacCodec(track.ExternalStreamCodec);
}
private static bool TargetAudioRequiresAac(string? profileAudioLabel) =>
!string.IsNullOrWhiteSpace(profileAudioLabel)
&& profileAudioLabel.Contains("aac", StringComparison.OrdinalIgnoreCase);
private static bool IsAacCodec(string? codecName) =>
!string.IsNullOrWhiteSpace(codecName)
&& codecName.Trim().Contains("aac", StringComparison.OrdinalIgnoreCase);
}