1218 lines
44 KiB
C#
1218 lines
44 KiB
C#
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);
|
||
}
|