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;
}
}