387 lines
12 KiB
C#
387 lines
12 KiB
C#
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);
|
||
|
||
/// <summary>
|
||
/// Запускает ffmpeg с <c>-progress pipe:1 -nostats</c>: асинхронно читает stdout (прогресс) и stderr
|
||
/// (обязательно, чтобы не заблокировался процесс), не блокируя UI.
|
||
/// </summary>
|
||
public sealed class FfmpegService
|
||
{
|
||
private const int MinProgressReportIntervalMs = 300;
|
||
private const string StatusRunning = "В работе";
|
||
|
||
public async Task<FfmpegRunResult> RunAsync(
|
||
FfmpegCommand command,
|
||
MediaAnalysisResult? media,
|
||
IProgress<FfmpegProgressSnapshot>? 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// <paramref name="totalFrameEstimate"/> — только для fallback, если в прогресс-стриме нет времени.
|
||
/// </summary>
|
||
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<FfmpegProgressSnapshot>? 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// out_time_ms / out_time_us / out_time. Значения из key=value по документации ffmpeg: *_ms часто = микросекунды; *_us = микросекунды.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Значение out_time_ms: встречается и как мс, и как мкс; выбираем согласованно с <paramref name="totalDurationMs"/>.</summary>
|
||
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;
|
||
}
|
||
}
|