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 _resolveHardwareAcceleration; private readonly SafeFileReplaceService _replace; private readonly ExternalFileCleanupService _cleanup; public ConversionExecutionService( LoggingService logging, FfmpegCommandBuilder builder, FfmpegService ffmpeg, FfprobeService ffprobe, FfmpegEncoderDiscoveryService encoderDiscovery, Func 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 items, Func resolveProfile, string? tempRoot, string runId, Func 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 resolveProfile, string tempRoot, string runId, Func 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 = 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( 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( 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); /// Ожидаемый нормализованный pix_fmt для проверки; с «без изменений» типичное yuv420p для AVC/HEVC. 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 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))); /// /// ffprobe может вернуть yuvj420p (JPEG full-range) там, где в профиле задано yuv420p (limited); /// по сути тот же 8-bit 4:2:0 планарный — для Emby/совместимости считаем допустимым совпадением. /// 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); } }