emby-toolbox/EmbyToolbox/Services/ConversionExecutionService.cs
2026-05-16 20:28:49 +05:00

757 lines
33 KiB
C#
Raw 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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using EmbyToolbox.Models;
namespace EmbyToolbox.Services;
public sealed class ConversionExecutionService
{
private readonly LoggingService _logging;
private readonly FfmpegCommandBuilder _builder;
private readonly FfmpegService _ffmpeg;
private readonly FfprobeService _ffprobe;
private readonly FfmpegEncoderDiscoveryService _encoderDiscovery;
private readonly Func<string> _resolveHardwareAcceleration;
private readonly SafeFileReplaceService _replace;
private readonly ExternalFileCleanupService _cleanup;
public ConversionExecutionService(
LoggingService logging,
FfmpegCommandBuilder builder,
FfmpegService ffmpeg,
FfprobeService ffprobe,
FfmpegEncoderDiscoveryService encoderDiscovery,
Func<string> resolveHardwareAcceleration,
SafeFileReplaceService replace,
ExternalFileCleanupService cleanup)
{
_logging = logging;
_builder = builder;
_ffmpeg = ffmpeg;
_ffprobe = ffprobe;
_encoderDiscovery = encoderDiscovery;
_resolveHardwareAcceleration = resolveHardwareAcceleration;
_replace = replace;
_cleanup = cleanup;
}
public async Task RunQueueAsync(
IReadOnlyList<ConversionQueueItem> items,
Func<string, ConversionProfileSettingsEntry?> resolveProfile,
string? tempRoot,
string runId,
Func<Action, Task> uiInvoke,
CancellationToken cancellationToken)
{
_logging.Info($"старт обработки очереди: {items.Count}", "conversion.exec");
_ = _encoderDiscovery.GetAvailableEncoders(_logging);
var root = EnsureTempRoot(tempRoot);
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
if (item.Status is not (ConversionQueueStatus.Ready or ConversionQueueStatus.Pending))
{
continue;
}
await RunSingleAsync(item, resolveProfile, root, runId, uiInvoke, cancellationToken).ConfigureAwait(false);
}
}
private async Task RunSingleAsync(
ConversionQueueItem item,
Func<string, ConversionProfileSettingsEntry?> resolveProfile,
string tempRoot,
string runId,
Func<Action, Task> uiInvoke,
CancellationToken token)
{
var tempOut = string.Empty;
var finalPath = item.FullPath;
var targetContainer = string.Empty;
FfmpegCommand? ffmpegCmdForDisposableDumps = null;
try
{
if (item.IsSkipPlan)
{
await uiInvoke(
() =>
{
item.Status = ConversionQueueStatus.Done;
item.Progress = 100;
item.IsProcessed = true;
item.ProcessedInCurrentRun = true;
item.LastRunId = runId;
item.ErrorMessage = null;
item.ErrorDetails = null;
}).ConfigureAwait(false);
_logging.Info($"Файл пропущен: обработка не требуется. {item.FullPath}", "conversion.exec");
return;
}
tempOut = Path.Combine(tempRoot, $"{Path.GetFileNameWithoutExtension(item.FileName)}.{Guid.NewGuid():N}.tmp{Path.GetExtension(item.FileName)}");
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Running;
item.Progress = 0;
item.IsProcessed = true;
item.ProcessedInCurrentRun = false;
item.LastRunId = runId;
item.ErrorMessage = null;
item.ErrorDetails = null;
}).ConfigureAwait(false);
var profile = item.EffectiveProfileSettings?.Profile
?? resolveProfile(item.Profile)
?? ConversionProfileMapping.EmbyFallback;
targetContainer = ResolveTargetContainer(item, profile);
finalPath = BuildFinalPath(item.FullPath, targetContainer);
tempOut = Path.Combine(tempRoot, $"{Path.GetFileNameWithoutExtension(item.FileName)}.__processing__{GetOutputExtension(targetContainer)}");
var selectedAcceleration = _resolveHardwareAcceleration();
var requiresVideoTranscode = RequiresVideoTranscode(item, profile);
var usedTsTimestampRetryTranscode = false;
if (MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName))
{
_logging.Info(
"MPEG-TS: включено исправление временных меток (genpts; avoid_negative_ts; muxdelay/muxpreload)",
"conversion.exec");
if (item.LastPlan?.RequiresTimestampFix == true)
{
_logging.Info(
"План: MPEG-TS → MKV — режим коррекции timestamps (при ошибке copy видео будет перекодирование)",
"conversion.exec");
}
}
var videoEncoder = ResolveVideoEncoderForItem(item, profile, selectedAcceleration, requiresVideoTranscode);
var cmd = _builder.Build(item, profile, tempOut, videoEncoder, requiresVideoTranscode);
ffmpegCmdForDisposableDumps = cmd;
if (!string.Equals(Path.GetExtension(item.FullPath), Path.GetExtension(finalPath), StringComparison.OrdinalIgnoreCase))
{
_logging.Info($"Remux: {Path.GetExtension(item.FullPath).TrimStart('.').ToUpperInvariant()} -> {Path.GetExtension(finalPath).TrimStart('.').ToUpperInvariant()}", "conversion.exec");
}
_logging.Info($"Начало обработки файла: {item.FullPath}", "conversion.exec");
_logging.Info($"План:{Environment.NewLine}{item.PlanSummary}", "conversion.exec");
_logging.Info($"Временный файл результата: {tempOut}", "conversion.exec");
_logging.Info($"Финальный файл: {finalPath}", "conversion.exec");
LogVideoEncodeSession(item, profile, cmd, videoEncoder);
LogPlannedActions(item, profile, cmd, requiresVideoTranscode);
_logging.Info("FFmpeg: старт объединенной обработки файла", "conversion.exec", command: cmd.FullCommand);
var ffProgress = new Progress<FfmpegProgressSnapshot>(
p =>
{
_ = uiInvoke(
() =>
{
if (p.IsIndeterminate)
{
if (item.Progress < 1)
{
item.Progress = 1;
}
}
else if (p.Percent is { } encPct)
{
// 0..99% ffmpeg → очередь 0..89 (floor); ffmpeg 100% (progress=end) → 90
var mapped = encPct >= 100
? 90
: Math.Clamp((int)Math.Floor(encPct / 100.0 * 90.0), 0, 89);
item.Progress = mapped;
}
});
});
var result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
if (!result.Success
&& !requiresVideoTranscode
&& MpegTsTimestampHelpers.IsMpegTsInput(item.MediaAnalysis, item.FileName)
&& MpegTsTimestampHelpers.LooksLikeTimestampMuxFailure(result.StdErr))
{
_logging.Warning(
"Video copy failed due to missing timestamps, retrying with video transcode",
"conversion.exec",
stderr: result.StdErr);
_logging.Info(
"MPEG-TS: повтор с перекодированием видео из-за ошибки временных меток",
"conversion.exec");
TryDeleteDisposableAttachmentDumpFiles(cmd);
TryDeleteTemp(tempOut);
tempOut = Path.Combine(
tempRoot,
$"{Path.GetFileNameWithoutExtension(item.FileName)}.__retryTs__.{Guid.NewGuid():N}{GetOutputExtension(targetContainer)}");
usedTsTimestampRetryTranscode = true;
var videoEncoderRetry = ResolveVideoEncoderForItem(item, profile, selectedAcceleration, true);
cmd = _builder.Build(item, profile, tempOut, videoEncoderRetry, requiresVideoTranscode: false, forceVideoTranscode: true);
ffmpegCmdForDisposableDumps = cmd;
_logging.Info($"Новый временный файл (повтор): {tempOut}", "conversion.exec");
LogVideoEncodeSession(item, profile, cmd, videoEncoderRetry);
_logging.Info("FFmpeg: повтор с перекодированием видео", "conversion.exec", command: cmd.FullCommand);
result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
}
if (!result.Success
&& requiresVideoTranscode
&& CommandArgumentsUseNvencVideo(cmd.ArgumentList)
&& LooksLikeNvencPipelineFailure(result.StdErr))
{
_logging.Warning(
"NVENC failed, retrying with libx264",
"conversion.exec",
stderr: result.StdErr);
TryDeleteDisposableAttachmentDumpFiles(cmd);
TryDeleteTemp(tempOut);
tempOut = Path.Combine(
tempRoot,
$"{Path.GetFileNameWithoutExtension(item.FileName)}.__retryCpuNv__.{Guid.NewGuid():N}{GetOutputExtension(targetContainer)}");
var cpuFallback = FfmpegCommandBuilder.CreateCpuFallbackVideoEncoder(item, profile);
cmd = _builder.Build(item, profile, tempOut, cpuFallback, requiresVideoTranscode);
ffmpegCmdForDisposableDumps = cmd;
LogVideoEncodeSession(item, profile, cmd, cpuFallback);
_logging.Info($"Новый временный файл (NVENC→CPU): {tempOut}", "conversion.exec");
_logging.Info("FFmpeg: повтор после сбоя NVENC (CPU кодер)", "conversion.exec", command: cmd.FullCommand);
result = await _ffmpeg.RunAsync(cmd, item.MediaAnalysis, ffProgress, token).ConfigureAwait(false);
}
if (!result.Success)
{
var err = string.IsNullOrWhiteSpace(result.StdErr) ? $"exit={result.ExitCode}" : ShortenForLog(result.StdErr, 2000);
_logging.Error($"FFmpeg завершен с ошибкой: {err}", "conversion.exec", stderr: result.StdErr);
var brief = string.IsNullOrWhiteSpace(result.StdErr)
? $"ffmpeg завершился с кодом {result.ExitCode}."
: ShortenForUiOneLine(result.StdErr.Trim(), 480);
var detail = string.IsNullOrWhiteSpace(result.StdErr) ? null : result.StdErr.Trim();
TryDeleteTemp(tempOut);
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Error;
item.ErrorMessage = brief;
item.ErrorDetails = detail;
item.ProcessedInCurrentRun = false;
}).ConfigureAwait(false);
return;
}
_logging.Info("FFmpeg завершен успешно", "conversion.exec");
await ValidateOutputAsync(tempOut, item, profile, requiresVideoTranscode || usedTsTimestampRetryTranscode, token).ConfigureAwait(false);
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Copying;
item.Progress = 90;
}).ConfigureAwait(false);
_logging.Info("Начато копирование результата", "conversion.exec");
var sameRoot = string.Equals(Path.GetPathRoot(finalPath), Path.GetPathRoot(tempOut), StringComparison.OrdinalIgnoreCase);
if (sameRoot)
{
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Replacing;
}).ConfigureAwait(false);
_logging.Info("Замена исходного файла", "conversion.exec");
}
var replaceProgress = new Progress<int>(
p =>
{
_ = uiInvoke(() => item.Progress = p);
});
await _replace.ReplaceAsync(item.FullPath, finalPath, tempOut, replaceProgress, token).ConfigureAwait(false);
_logging.Info("успешная замена исходника", "conversion.exec");
if (!string.Equals(item.FullPath, finalPath, StringComparison.OrdinalIgnoreCase))
{
_logging.Info("Старый файл удален после успешной замены", "conversion.exec");
}
var usedFonts = cmd.UsedExternalFiles
.Where(x => x.IsFont)
.DistinctBy(x => x.FullPath)
.Count();
if (usedFonts > 0)
{
_logging.Info($"Использованы шрифты: {usedFonts}", "conversion.exec");
_logging.Info("Шрифты оставлены на месте", "conversion.exec");
}
var moved = _cleanup.MoveUsedToUseless(finalPath, cmd.UsedExternalFiles);
if (moved.Count > 0)
{
_logging.Info($"Перемещены внешние файлы в useless: {moved.Count}", "conversion.exec");
}
await uiInvoke(() =>
{
item.UpdateOutputPath(finalPath, targetContainer);
item.Progress = 100;
item.Status = ConversionQueueStatus.Done;
item.ProcessedInCurrentRun = true;
}).ConfigureAwait(false);
_logging.Info($"Итоговый путь обновлен: {finalPath}", "conversion.exec");
_logging.Info("Файл обработан успешно", "conversion.exec");
}
catch (OperationCanceledException)
{
_logging.Warning("отмена пользователем", "conversion.exec");
TryDeleteTemp(tempOut);
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Cancelled;
item.ErrorMessage = "Отмена пользователем";
item.ErrorDetails = null;
}).ConfigureAwait(false);
throw;
}
catch (Exception ex)
{
TryDeleteTemp(tempOut);
_logging.Error($"ошибка обработки: {ex.Message}", "conversion.exec", ex);
await uiInvoke(() =>
{
item.Status = ConversionQueueStatus.Error;
item.ErrorMessage = ex.Message;
item.ErrorDetails = null;
}).ConfigureAwait(false);
}
finally
{
TryDeleteDisposableAttachmentDumpFiles(ffmpegCmdForDisposableDumps);
}
}
private static void TryDeleteDisposableAttachmentDumpFiles(FfmpegCommand? cmd)
{
if (cmd?.DisposableAttachmentDumpPaths is not { Count: > 0 })
{
return;
}
foreach (var path in cmd.DisposableAttachmentDumpPaths.Distinct(StringComparer.OrdinalIgnoreCase))
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// временные дампы для -attach после завершения ffmpeg
}
}
}
private void LogPlannedActions(ConversionQueueItem item, ConversionProfileSettingsEntry profile, FfmpegCommand cmd, bool requiresVideoTranscode)
{
var ovr = item.TaskOverride;
var targetContainer = string.IsNullOrWhiteSpace(ovr.TargetContainer) ? profile.Container : ovr.TargetContainer;
var sourceContainer = item.MediaAnalysis?.ContainerFormat ?? "unknown";
if (!string.Equals(sourceContainer, targetContainer, StringComparison.OrdinalIgnoreCase))
{
_logging.Info($"FFmpeg: старт remux контейнера {sourceContainer} -> {targetContainer}", "conversion.exec");
}
var sourceVideo = item.MediaAnalysis?.PrimaryVideo?.CodecName ?? "unknown";
var targetVideo = string.IsNullOrWhiteSpace(ovr.TargetVideo) ? profile.Video : ovr.TargetVideo;
if (requiresVideoTranscode || ovr.TrackOverrides.Any(t => t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert))
{
_logging.Info($"FFmpeg: старт конвертации видео {sourceVideo} -> {targetVideo}", "conversion.exec");
}
var sb = new StringBuilder();
sb.AppendLine("FFmpeg команда для файла:");
var mappedLines = 0;
if (!string.Equals(sourceContainer, targetContainer, StringComparison.OrdinalIgnoreCase))
{
sb.AppendLine($"- remux в {targetContainer}");
mappedLines++;
}
if (!requiresVideoTranscode && ovr.TrackOverrides.Any(t => t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Keep))
{
sb.AppendLine("- video copy");
mappedLines++;
}
foreach (var t in ovr.TrackOverrides.OrderBy(x => x.StreamKind).ThenBy(x => x.StreamIndex))
{
var src = GetSourceStream(item, t);
var lang = FormatLang(src?.Language ?? t.Language);
if (t.Action == TrackActionKind.Convert && t.StreamKind == MediaStreamKind.Audio)
{
var fromCodec = src?.CodecName ?? "unknown";
var bitrate = string.IsNullOrWhiteSpace(t.AudioBitrateKbps) ? (string.IsNullOrWhiteSpace(ovr.TargetAudioBitrate) ? profile.Bitrate : ovr.TargetAudioBitrate) : t.AudioBitrateKbps!;
_logging.Info($"FFmpeg: старт конвертации audio #{DisplayIndex(t)} {fromCodec} -> aac {bitrate}", "conversion.exec");
sb.AppendLine($"- audio #{DisplayIndex(t)} convert to AAC {bitrate}");
mappedLines++;
continue;
}
if (t.Action == TrackActionKind.Remove && t.Source == SourceKind.Embedded && t.StreamKind is MediaStreamKind.Audio or MediaStreamKind.Subtitle)
{
var kind = t.StreamKind == MediaStreamKind.Audio ? "audio" : "subtitle";
var subKind = string.Empty;
if (t.StreamKind == MediaStreamKind.Subtitle &&
SubtitleCodecRules.IsTeletext(src?.CodecName))
{
subKind = " (teletext)";
}
_logging.Info($"FFmpeg: старт удаления дорожки {kind}{subKind} #{DisplayIndex(t)} ({lang})", "conversion.exec");
sb.AppendLine(subKind.Length > 0
? $"- remove teletext subtitle #{DisplayIndex(t)}"
: $"- remove {kind} #{DisplayIndex(t)}");
mappedLines++;
continue;
}
if (t.Action == TrackActionKind.Add && t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Audio && !string.IsNullOrWhiteSpace(t.ExternalPath))
{
_logging.Info($"FFmpeg: старт добавления внешней аудиодорожки: {Path.GetFileName(t.ExternalPath)}", "conversion.exec");
sb.AppendLine("- add external audio");
mappedLines++;
continue;
}
if (t.Action == TrackActionKind.Add && t.Source == SourceKind.External && t.StreamKind == MediaStreamKind.Subtitle && !string.IsNullOrWhiteSpace(t.ExternalPath))
{
_logging.Info($"FFmpeg: старт добавления внешних субтитров: {Path.GetFileName(t.ExternalPath)}", "conversion.exec");
sb.AppendLine("- add external subtitle");
mappedLines++;
continue;
}
}
var fonts = cmd.UsedExternalFiles.Where(f => f.IsFont).DistinctBy(f => f.FullPath).Count();
if (fonts > 0)
{
_logging.Info($"FFmpeg: старт встраивания шрифтов: {fonts} файлов", "conversion.exec");
sb.AppendLine($"- attach fonts: {fonts}");
mappedLines++;
}
if (mappedLines == 0)
{
sb.AppendLine("- copy streams");
}
_logging.Info(sb.ToString().TrimEnd(), "conversion.exec");
}
private static MediaStreamInfo? GetSourceStream(ConversionQueueItem item, TrackOverrideEntry t)
{
if (t.Source != SourceKind.Embedded || t.StreamIndex < 0 || item.MediaAnalysis is null)
{
return null;
}
return item.MediaAnalysis.AllStreams.FirstOrDefault(s => s.Index == t.StreamIndex);
}
private static int DisplayIndex(TrackOverrideEntry t) => t.StreamIndex >= 0 ? t.StreamIndex + 1 : -1;
private static string FormatLang(string? lang) => string.IsNullOrWhiteSpace(lang) ? "unknown" : lang.Trim();
private static string ShortenForUiOneLine(string text, int maxLen)
{
var one = text.ReplaceLineEndings(" ").Trim();
while (one.Contains(" ", StringComparison.Ordinal))
{
one = one.Replace(" ", " ", StringComparison.Ordinal);
}
return ShortenForLog(one, maxLen);
}
private static string ShortenForLog(string text, int maxLen)
{
if (text.Length <= maxLen)
{
return text;
}
return text[..maxLen] + "...";
}
private static string EnsureTempRoot(string? path)
{
var root = string.IsNullOrWhiteSpace(path)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "EmbyToolbox", "Temp")
: path.Trim();
Directory.CreateDirectory(root);
return root;
}
private static void TryDeleteTemp(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// ignore
}
}
private static string ResolveTargetContainer(ConversionQueueItem item, ConversionProfileSettingsEntry profile)
{
var target = string.IsNullOrWhiteSpace(item.TaskOverride.TargetContainer) ? profile.Container : item.TaskOverride.TargetContainer;
return string.IsNullOrWhiteSpace(target) ? profile.Container : target.Trim();
}
private static string BuildFinalPath(string sourcePath, string targetContainer)
{
var dir = Path.GetDirectoryName(sourcePath) ?? string.Empty;
var name = Path.GetFileNameWithoutExtension(sourcePath);
return Path.Combine(dir, name + GetOutputExtension(targetContainer));
}
private static string GetOutputExtension(string targetContainer)
{
if (targetContainer.Contains("mkv", StringComparison.OrdinalIgnoreCase) || targetContainer.Contains("matro", StringComparison.OrdinalIgnoreCase))
{
return ".mkv";
}
if (targetContainer.Contains("mp4", StringComparison.OrdinalIgnoreCase) || targetContainer.Contains("mov", StringComparison.OrdinalIgnoreCase))
{
return ".mp4";
}
return ".mkv";
}
private VideoEncoderSettings? ResolveVideoEncoderForItem(
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
string selectedAcceleration,
bool requiresVideoTranscode)
{
if (!requiresVideoTranscode)
{
return null;
}
var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo)
? profile.Video
: item.TaskOverride.TargetVideo;
return _encoderDiscovery.ResolveVideoEncoder(
selectedAcceleration,
targetVideo,
autoFallbackToCpu: true,
_logging);
}
private static bool RequiresVideoTranscode(ConversionQueueItem item, ConversionProfileSettingsEntry profile)
{
if (item.MediaAnalysis is null)
{
return item.TaskOverride.TrackOverrides.Any(t =>
t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert);
}
return ConversionPlanService.RequiresVideoTranscode(item.MediaAnalysis, profile, item.TaskOverride)
|| item.TaskOverride.TrackOverrides.Any(t =>
t.StreamKind == MediaStreamKind.Video && t.Action == TrackActionKind.Convert);
}
private async Task ValidateOutputAsync(
string outputPath,
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
bool requiresVideoTranscode,
CancellationToken token)
{
if (!requiresVideoTranscode)
{
return;
}
var probe = await _ffprobe.AnalyzeAsync(outputPath, token).ConfigureAwait(false);
if (!probe.IsSuccess)
{
throw new InvalidOperationException("ffprobe результата завершился с ошибкой");
}
var parsed = MediaAnalysisParser.TryParse(probe.Json);
var video = parsed?.PrimaryVideo;
if (video is null)
{
throw new InvalidOperationException("в результирующем файле не найден видеопоток");
}
var targetVideo = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo;
var expectedCodec = targetVideo.Contains("265", StringComparison.OrdinalIgnoreCase)
|| targetVideo.Contains("hevc", StringComparison.OrdinalIgnoreCase)
? "hevc"
: "h264";
var actualCodec = video.CodecName ?? string.Empty;
var codecOk = expectedCodec == "h264"
? actualCodec.Contains("h264", StringComparison.OrdinalIgnoreCase) || actualCodec.Contains("avc", StringComparison.OrdinalIgnoreCase)
: actualCodec.Contains("hevc", StringComparison.OrdinalIgnoreCase) || actualCodec.Contains("h265", StringComparison.OrdinalIgnoreCase);
if (!codecOk)
{
throw new InvalidOperationException($"проверка результата не пройдена: video codec={actualCodec}, ожидается {expectedCodec}");
}
var targetPixelUi = string.IsNullOrWhiteSpace(item.TaskOverride.TargetPixelFormat)
? profile.PixelFormat
: item.TaskOverride.TargetPixelFormat;
var expectedNorm = ResolveExpectedOutputPixNorm(targetPixelUi, expectedCodec);
if (expectedNorm is null)
{
return;
}
var actualNorm = FfmpegCommandBuilder.NormalizeFfprobePixelFormat(video.PixelFormat);
if (string.IsNullOrWhiteSpace(actualNorm))
{
throw new InvalidOperationException(
$"проверка результата не пройдена: pixel format недоступен (ffprobe: {video.PixelFormat ?? "(null)"})");
}
if (!PixelFormatsMatchForValidation(expectedNorm, actualNorm))
{
throw new InvalidOperationException(
$"проверка результата не пройдена: pix_fmt={video.PixelFormat}, ожидается {expectedNorm}");
}
}
private static bool IsPixelFormatUiOpen(string? s) =>
!string.IsNullOrWhiteSpace(s)
&& !s.Contains("без", StringComparison.OrdinalIgnoreCase)
&& !s.Contains("No change", StringComparison.OrdinalIgnoreCase);
/// <summary>Ожидаемый нормализованный pix_fmt для проверки; с «без изменений» типичное yuv420p для AVC/HEVC.</summary>
private static string? ResolveExpectedOutputPixNorm(string mergedPixelFmtUiFromProfile, string expectedCodecFourccStyle)
{
if (IsPixelFormatUiOpen(mergedPixelFmtUiFromProfile))
{
return FfmpegCommandBuilder.NormalizeFfprobePixelFormat(mergedPixelFmtUiFromProfile!.Trim());
}
if (expectedCodecFourccStyle.Equals("hevc", StringComparison.OrdinalIgnoreCase)
|| expectedCodecFourccStyle.Equals("h264", StringComparison.OrdinalIgnoreCase))
{
return "yuv420p";
}
return null;
}
private void LogVideoEncodeSession(
ConversionQueueItem item,
ConversionProfileSettingsEntry profile,
FfmpegCommand cmd,
VideoEncoderSettings? encoderHint)
{
if (cmd.VideoEncoderForLog is null)
{
return;
}
var pv = item.MediaAnalysis?.PrimaryVideo;
var srcPix = FfmpegCommandBuilder.NormalizeFfprobePixelFormat(pv?.PixelFormat) ?? "?";
var srcCodec = string.IsNullOrWhiteSpace(pv?.CodecName) ? "?" : pv.CodecName;
var mergedPxUi = FfmpegCommandBuilder.ResolveMergedTargetPixelFormatUi(item.TaskOverride, profile);
string tgtEffPix;
var codecForEff = encoderHint?.Codec ?? cmd.VideoEncoderForLog;
tgtEffPix = FfmpegCommandBuilder.TryGetEffectiveVideoOutputPixFmt(mergedPxUi, codecForEff, out var effPx)
? effPx!
: "?";
var mergedVidRaw = string.IsNullOrWhiteSpace(item.TaskOverride.TargetVideo) ? profile.Video : item.TaskOverride.TargetVideo;
var tgtVid = FormatMergedTargetVideoForLog(mergedVidRaw);
_logging.Info($"Video transcode: {srcCodec} / {srcPix} -> {tgtVid} / {tgtEffPix}", "conversion.exec");
if (cmd.AppliedVideoFilters.Count > 0)
{
_logging.Info($"Video filter: {string.Join(",", cmd.AppliedVideoFilters)}", "conversion.exec");
}
_logging.Info($"Encoder: {cmd.VideoEncoderForLog}", "conversion.exec");
}
private static string FormatMergedTargetVideoForLog(string mergedVideo)
{
var v = mergedVideo.Trim();
if (string.IsNullOrWhiteSpace(v))
{
return "?";
}
if (v.Contains("265", StringComparison.OrdinalIgnoreCase)
|| v.Contains("hevc", StringComparison.OrdinalIgnoreCase))
{
return "H.265 / HEVC";
}
if (v.Contains("264", StringComparison.OrdinalIgnoreCase))
{
return "H.264";
}
return v;
}
private static bool CommandArgumentsUseNvencVideo(IReadOnlyList<string> args)
{
for (var i = 0; i < args.Count - 1; i++)
{
if (!args[i].StartsWith("-c:v", StringComparison.Ordinal))
{
continue;
}
if (args[i + 1].Contains("nvenc", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static bool LooksLikeNvencPipelineFailure(string stderr) =>
!string.IsNullOrWhiteSpace(stderr) &&
(stderr.Contains("Impossible to convert between the formats", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("auto_scale_", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("Error reinitializing filters", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("Function not implemented", StringComparison.OrdinalIgnoreCase)
|| (stderr.Contains("nvenc", StringComparison.OrdinalIgnoreCase)
&& stderr.Contains("Could not open encoder", StringComparison.OrdinalIgnoreCase)));
/// <summary>
/// ffprobe может вернуть yuvj420p (JPEG full-range) там, где в профиле задано yuv420p (limited);
/// по сути тот же 8-bit 4:2:0 планарный — для Emby/совместимости считаем допустимым совпадением.
/// </summary>
private static bool PixelFormatsMatchForValidation(string expectedNorm, string actualNorm)
{
if (string.Equals(expectedNorm, actualNorm, StringComparison.OrdinalIgnoreCase))
{
return true;
}
static bool IsYuv420pFamily(string p) =>
p.Equals("yuv420p", StringComparison.OrdinalIgnoreCase)
|| p.Equals("yuvj420p", StringComparison.OrdinalIgnoreCase);
return IsYuv420pFamily(expectedNorm) && IsYuv420pFamily(actualNorm);
}
}