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 ArgumentList, IReadOnlyList UsedExternalFiles, IReadOnlyList DisposableAttachmentDumpPaths, IReadOnlyList AppliedVideoFilters, string? VideoEncoderForLog) { /// Только для логов и UI; не использовать для запуска процесса. public string FullCommand => FormatCommandLineForDisplay(Executable, ArgumentList); public static string FormatCommandLineForDisplay(string executable, IReadOnlyList argumentList) { var parts = new List(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(); var disposableAttachmentDumps = new List(); var supportsAttachmentsOut = SupportsAttachments(ovr.TargetContainer, profile.Container); var embeddedMuxPlans = CollectEmbeddedAttachmentMuxPlans(item, supportsAttachmentsOut, outputPath, disposableAttachmentDumps); var appliedVideoFilters = new List(); string? encoderForLog = null; var args = new List { "-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(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(); 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); } /// /// Цепочка видеофильтров: fps → scale → format (совпадает с логикой плана транскодирования). /// public static IReadOnlyList BuildVideoFilterChain( ConversionQueueItem item, ConversionProfileSettingsEntry profile, string? videoEncoderCodec = null) { var merged = MergeTargets(item.TaskOverride, profile); var v = item.MediaAnalysis?.PrimaryVideo; var list = new List(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; } /// Итоговый pix_fmt для фильтра/профиля: явный из UI или типичный для кодера (NVENC/x264). public static bool TryGetEffectiveVideoOutputPixFmt(string mergedPixelFmtUi, string encoderCodec, [NotNullWhen(true)] out string? pixNorm) => TryResolveEffectiveTargetPixelNormalized(mergedPixelFmtUi, encoderCodec, out pixNorm); /// Нормализует pix_fmt из ffprobe (обрезка «(tv, …)»). 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 args, string? extra) { if (string.IsNullOrWhiteSpace(extra)) { return; } foreach (var tok in extra.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries)) { args.Add(tok); } } /// /// План переупаковки встроенного вложения: общий ffmpeg mux запрещает пакеты AVMEDIA_TYPE_ATTACHMENT /// (см. libavformat mux.c → Received a packet for an attachment stream → EINVAL). /// Снимаем вложение в файл (-dump_attachment) и подмешиваем через -attach. /// private readonly record struct EmbeddedAttachmentMuxPlan( int TypeOrdinal, string TempDumpFullPath, string? DeclaredFileNameForMetadata, string MimeType); private static List CollectEmbeddedAttachmentMuxPlans( ConversionQueueItem item, bool supportsAttachmentsOut, string outputPath, List disposableAttachmentDumps) { var list = new List(); if (!supportsAttachmentsOut || item.MediaAnalysis is not { } media) { return list; } var handledAttachmentStreams = new HashSet(); 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; } /// Индекс вложения для -dump_attachment:t:N среди дорожек codec_type attachment (порядок по stream index). 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 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"); } /// Порядок как ожидает NVENC/libav: уже после —c:v— цепочка —vf/—filter:v— затем —pix_fmt— и опции кодера. private static void AppendVideoTranscodeFiltersThenPixFmt( List args, ConversionQueueItem item, ConversionProfileSettingsEntry profile, List 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!); } } } /// /// Любой выход yuv420p через libx264 и аппаратные AVC-энкодеры (nvenc/qsv/amf…) при 10-бит входе требует явного format= в -filter. /// private static void AppendRequiredFormat420ForRestrictedH264IfPix420p( List 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 args, IReadOnlyList 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 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); } /// /// Hi10 yuv420p10le при цели yuv420p + AVC/NVENC: для разметки BT.2020 (частые BDRip) недостаточно format=yuv420p — /// colorspace задаёт связный даунграйд без auto_scale по цвету. /// 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; } /// Из подписей UI («Максимум 1080p», «Максимум 60» fps) достаём числовой порог. 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); }