using System.Diagnostics; using System.Globalization; using System.Text; using EmbyToolbox.Models; namespace EmbyToolbox.Services; public sealed record FfmpegProgressSnapshot(int? Percent, bool IsIndeterminate, string StatusText); public sealed record FfmpegRunResult(bool Success, string StdErr, int ExitCode); /// /// Запускает ffmpeg с -progress pipe:1 -nostats: асинхронно читает stdout (прогресс) и stderr /// (обязательно, чтобы не заблокировался процесс), не блокируя UI. /// public sealed class FfmpegService { private const int MinProgressReportIntervalMs = 300; private const string StatusRunning = "В работе"; public async Task RunAsync( FfmpegCommand command, MediaAnalysisResult? media, IProgress? progress, CancellationToken cancellationToken) { var start = new ProcessStartInfo { FileName = command.Executable, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; foreach (var a in command.ArgumentList) { start.ArgumentList.Add(a); } using var p = new Process { StartInfo = start }; p.Start(); // stderr обязан читаться параллельно с progress на stdout, иначе буфер заполняется и процесс встанет var stderrV = p.StandardError.ReadToEndAsync(cancellationToken); using (cancellationToken.Register( static state => { try { if (state is Process proc && !proc.HasExited) { proc.Kill(true); } } catch { // ignore } }, p)) { try { var totalDurationMs = ResolveTotalDurationMs(media, out var totalFrameEstimate); await ReadProgressFromStdoutAsync( p, totalDurationMs, totalFrameEstimate, progress, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } } await p.WaitForExitAsync(cancellationToken).ConfigureAwait(false); string errText; try { errText = await stderrV.ConfigureAwait(false); } catch { errText = string.Empty; } return new FfmpegRunResult(p.ExitCode == 0, errText, p.ExitCode); } /// /// — только для fallback, если в прогресс-стриме нет времени. /// private static double? ResolveTotalDurationMs(MediaAnalysisResult? media, out double? totalFrameEstimate) { totalFrameEstimate = null; if (media is null) { return null; } var sec = media.GetEffectiveDurationSeconds(); if (sec is not { } d || d <= 0) { return null; } var totalMs = d * 1000.0; var fps = media.PrimaryVideo?.FrameRate; if (fps is { } f && f > 0) { totalFrameEstimate = f * d; } return totalMs; } private static async Task ReadProgressFromStdoutAsync( Process p, double? totalDurationMs, double? totalFrameEstimate, IProgress? progress, CancellationToken cancellationToken) { var outReader = p.StandardOutput; var lastReportedPercent = -1; var lastReportTime = long.MinValue; var anyTimeProgress = false; string? line; while ((line = await outReader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null) { if (line.Equals("progress=end", StringComparison.OrdinalIgnoreCase)) { // 100% фазы кодирования (снаружи маппится 0–90% очереди) lastReportedPercent = 100; progress?.Report(new FfmpegProgressSnapshot(100, false, StatusRunning)); continue; } if (TryParseTimeProgressMs(line, totalDurationMs, out var outMs) && totalDurationMs is { } tdm && tdm > 0) { anyTimeProgress = true; var pct = (int)Math.Clamp(outMs / tdm * 100.0, 0, 100); if (ShouldReportThrottled(pct, false, lastReportedPercent, lastReportTime, out lastReportTime)) { lastReportedPercent = pct; progress?.Report(new FfmpegProgressSnapshot(pct, false, StatusRunning)); } continue; } if (!anyTimeProgress && totalDurationMs is not null && totalFrameEstimate is { } tfe && tfe > 0 && line.StartsWith("frame=", StringComparison.Ordinal) && TryGetFrameCount(line, out var frame) && frame >= 0) { var pct = (int)Math.Clamp(frame / tfe * 100.0, 0, 100); if (ShouldReportThrottled(pct, false, lastReportedPercent, lastReportTime, out lastReportTime)) { lastReportedPercent = pct; progress?.Report(new FfmpegProgressSnapshot(pct, false, StatusRunning)); } } else if (totalDurationMs is null && line.StartsWith("frame=", StringComparison.Ordinal)) { if (lastReportedPercent < 0) { // редко: только кадры без длительности if (ShouldReportThrottled(0, true, lastReportedPercent, lastReportTime, out lastReportTime)) { lastReportedPercent = 0; progress?.Report(new FfmpegProgressSnapshot(null, true, StatusRunning)); } } } } // если ни одной time-строки не было — индетерминат, один раз if (lastReportedPercent < 0 && !anyTimeProgress) { progress?.Report(new FfmpegProgressSnapshot(null, true, StatusRunning)); } } private static bool ShouldReportThrottled( int newPercent, bool force, int lastReported, long lastTimeMs, out long newLastTime) { newLastTime = lastTimeMs; if (force) { newLastTime = Environment.TickCount64; return true; } var now = Environment.TickCount64; if (lastTimeMs == long.MinValue || now - lastTimeMs >= MinProgressReportIntervalMs || newPercent > lastReported + 4) { newLastTime = now; return true; } return false; } /// /// out_time_ms / out_time_us / out_time. Значения из key=value по документации ffmpeg: *_ms часто = микросекунды; *_us = микросекунды. /// private static bool TryParseTimeProgressMs(string line, double? totalDurationMs, out double outTimeMs) { outTimeMs = 0; if (line.StartsWith("out_time_ms=", StringComparison.Ordinal)) { return TryParseOutTimeMsField(line["out_time_ms=".Length..], totalDurationMs, out outTimeMs); } if (line.StartsWith("out_time_us=", StringComparison.Ordinal)) { return TryParseOutTimeUsField(line["out_time_us=".Length..], out outTimeMs); } if (line.StartsWith("out_time=", StringComparison.Ordinal)) { return TryParseOutTimeString(line["out_time=".Length..], out outTimeMs); } return false; } /// Значение out_time_ms: встречается и как мс, и как мкс; выбираем согласованно с . private static bool TryParseOutTimeMsField(string raw, double? totalDurationMs, out double outTimeMs) { outTimeMs = 0; var s = raw.AsSpan().Trim(); if (s.Length == 0 || s.Equals("N/A", StringComparison.OrdinalIgnoreCase)) { return false; } if (!double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var n)) { return false; } if (n < 0) { return false; } var asTrueMs = n; var asFromMicro = n / 1000.0; if (totalDurationMs is { } t && t > 0) { var leeway = 5000.0; if (asTrueMs <= t + leeway) { outTimeMs = asTrueMs; return true; } if (asFromMicro <= t + leeway) { outTimeMs = asFromMicro; return true; } } outTimeMs = asFromMicro; return true; } private static bool TryParseOutTimeUsField(string raw, out double outTimeMs) { outTimeMs = 0; var s = raw.AsSpan().Trim(); if (s.Length == 0 || s.Equals("N/A", StringComparison.OrdinalIgnoreCase)) { return false; } if (!double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var n)) { return false; } outTimeMs = n / 1000.0; // микросекунды → мс return outTimeMs >= 0; } private static bool TryParseOutTimeString(string raw, out double outTimeMs) { outTimeMs = 0; var t = raw.AsSpan().Trim(); if (t.Length == 0 || t.Equals("N/A", StringComparison.OrdinalIgnoreCase)) { return false; } if (t.Contains(':')) { // HH:MM:SS[.fract] или 00:00:01.00 var c = 0; for (var i = 0; i < t.Length; i++) { if (t[i] == ':') { c++; } } if (c == 2) { var p1 = t.IndexOf(':'); var p2 = t[(p1 + 1)..].IndexOf(':') + p1 + 1; if (p1 > 0 && p2 > p1 && int.TryParse(t[..p1], out var h) && int.TryParse(t[(p1 + 1)..p2], out var m) && double.TryParse(t[(p2 + 1)..], NumberStyles.Any, CultureInfo.InvariantCulture, out var sec)) { outTimeMs = (h * 3600.0 + m * 60.0 + sec) * 1000.0; return outTimeMs >= 0; } } } if (double.TryParse(t, NumberStyles.Any, CultureInfo.InvariantCulture, out var seconds)) { outTimeMs = seconds * 1000.0; return outTimeMs >= 0; } return false; } private static bool TryGetFrameCount(string line, out int frame) { frame = 0; if (!line.StartsWith("frame=", StringComparison.Ordinal)) { return false; } var i = 6; while (i < line.Length && char.IsWhiteSpace(line[i])) { i++; } if (i >= line.Length) { return false; } var f = 0; var any = false; for (; i < line.Length; i++) { var c = line[i]; if (!char.IsDigit(c)) { break; } f = f * 10 + (c - '0'); any = true; } if (!any) { return false; } frame = f; return true; } }