emby-toolbox/EmbyToolbox/Services/FfmpegService.cs
Emby Toolbox 6264b487fe Initial commit: Emby Toolbox (conversion scroll fix, bulk Del for tracks).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 21:33:47 +05:00

387 lines
12 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.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% фазы кодирования (снаружи маппится 090% очереди)
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;
}
}