ow
This commit is contained in:
1 parent
bcf82d0f45
commit
8260c417e2
7 files changed
+1148
-44
No files matched your search
@@ -265,11 +265,24 @@ namespace WinFormsApp3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4(直接 copy 不重编码)。
|
||||
/// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4。
|
||||
/// 优先两条流直接 copy:不重编码、时间戳原样保留,音画对齐最准确;
|
||||
/// 只有音频轨格式装不进 mp4 容器时才回退到 AAC 转码。
|
||||
/// </summary>
|
||||
private static async Task<bool> MuxDashAsync(string ffmpeg, string videoFile, string audioFile, string outputFile, CancellationToken ct)
|
||||
{
|
||||
var args = $"-hide_banner -loglevel error -y -i \"{videoFile}\" -i \"{audioFile}\" -c:v copy -c:a aac -strict experimental -movflags +faststart \"{outputFile}\"";
|
||||
// 只取视频轨的视频流 + 音频轨的音频流(没音频流也照常出片),-shortest 把两条轨截齐到同一时长
|
||||
var copyArgs = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{videoFile}\" -i \"{audioFile}\" -map 0:v:0 -map 1:a:0? -c copy -shortest -movflags +faststart \"{outputFile}\"";
|
||||
if (await RunFfmpegAsync(ffmpeg, copyArgs, outputFile, ct)) return true;
|
||||
|
||||
TryDelete(outputFile);
|
||||
|
||||
var aacArgs = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{videoFile}\" -i \"{audioFile}\" -map 0:v:0 -map 1:a:0? -c:v copy -c:a aac -strict experimental -shortest -movflags +faststart \"{outputFile}\"";
|
||||
return await RunFfmpegAsync(ffmpeg, aacArgs, outputFile, ct);
|
||||
}
|
||||
|
||||
private static async Task<bool> RunFfmpegAsync(string ffmpeg, string args, string outputFile, CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpeg,
|
||||
@@ -291,6 +304,170 @@ namespace WinFormsApp3
|
||||
return proc.ExitCode == 0 && File.Exists(outputFile) && new FileInfo(outputFile).Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 抽帧结果:成功时 Frames 非空;失败时 Error 给界面显示原因,不让用户干等。
|
||||
/// </summary>
|
||||
public record FrameExtraction(List<byte[]> Frames, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// 抽取预览截图:每 intervalSeconds 秒取一张、共取 frameCount 张(默认 5 秒 3 张),
|
||||
/// 供下载列表肉眼对比哪个是当前视频。用 ffmpeg 直接读远端地址,带 Referer/Cookie 过防盗链。
|
||||
/// 单次尝试限 25 秒,超时不再重试第二次,避免地址失效时列表长时间卡在“生成中”。
|
||||
/// </summary>
|
||||
public async Task<FrameExtraction> ExtractFramesAsync(string url, CancellationToken ct, int intervalSeconds = 5, int frameCount = 3)
|
||||
{
|
||||
var ffmpeg = FindFfmpeg();
|
||||
if (ffmpeg == null)
|
||||
{
|
||||
return new FrameExtraction(new List<byte[]>(), "未找到 ffmpeg,无法生成截图");
|
||||
}
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
return new FrameExtraction(new List<byte[]>(), "该地址无法直接抽帧");
|
||||
}
|
||||
|
||||
var tmpDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tmpDir);
|
||||
|
||||
try
|
||||
{
|
||||
string? lastError = null;
|
||||
|
||||
// 先试从第 2 秒开始抽(片头常有黑帧),只有上一次正常退出且没拿到帧才退回 0 秒再试
|
||||
foreach (var startAt in new[] { 2, 0 })
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var args = new List<string>
|
||||
{
|
||||
"-hide_banner", "-loglevel", "error", "-nostdin", "-y",
|
||||
// 远端卡住时快速失败:单次 I/O 12 秒,探测上限 2MB/2秒,不靠外部杀进程兑底
|
||||
"-rw_timeout", "12000000",
|
||||
"-probesize", "2000000", "-analyzeduration", "2000000",
|
||||
"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2"
|
||||
};
|
||||
var headers = BuildFfmpegHeaders();
|
||||
if (headers.Length > 0)
|
||||
{
|
||||
args.Add("-headers");
|
||||
args.Add(headers);
|
||||
}
|
||||
if (IsHlsUrl(url))
|
||||
{
|
||||
args.Add("-protocol_whitelist");
|
||||
args.Add("file,http,https,tcp,tls,crypto");
|
||||
}
|
||||
args.AddRange(new[]
|
||||
{
|
||||
"-ss", startAt.ToString(), "-i", url,
|
||||
"-t", (intervalSeconds * frameCount).ToString(),
|
||||
"-vf", $"fps=1/{intervalSeconds},scale=160:-2",
|
||||
"-frames:v", frameCount.ToString(), "-an",
|
||||
Path.Combine(tmpDir, "f_%d.jpg")
|
||||
});
|
||||
|
||||
var run = await RunFfmpegArgsAsync(ffmpeg, args, ct);
|
||||
|
||||
var files = Directory.GetFiles(tmpDir, "f_*.jpg").OrderBy(f => f, StringComparer.Ordinal).ToList();
|
||||
if (files.Count > 0)
|
||||
{
|
||||
return new FrameExtraction(files.Select(File.ReadAllBytes).ToList(), null);
|
||||
}
|
||||
|
||||
lastError = run.Error ?? "没抽到帧";
|
||||
if (run.TimedOut)
|
||||
{
|
||||
break; // 地址多半已失效或被防盗链卡住,再试一次只会多等半分钟
|
||||
}
|
||||
}
|
||||
|
||||
return new FrameExtraction(new List<byte[]>(), lastError ?? "没抽到帧");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(tmpDir, true); } catch { /* 临时目录删不掉不影响结果 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ffmpeg 走网络也要过防盗链:把下载器身上的 UA / Referer / Cookie 拼成 -headers 参数。
|
||||
/// </summary>
|
||||
private string BuildFfmpegHeaders()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"User-Agent: {UserAgent}\r\n");
|
||||
if (_referer != null) sb.Append($"Referer: {_referer}\r\n");
|
||||
if (_cookie != null) sb.Append($"Cookie: {_cookie}\r\n");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>ffmpeg 单次运行结果:TimedOut 表示被外部杀掉,Error 给界面显示原因。</summary>
|
||||
private record FfmpegRun(bool TimedOut, string? Error);
|
||||
|
||||
private static async Task<FfmpegRun> RunFfmpegArgsAsync(string ffmpeg, List<string> args, CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = ffmpeg,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc == null) return new FfmpegRun(false, "ffmpeg 启动失败");
|
||||
|
||||
// 就算 -rw_timeout 失效(DNS 卡住、TLS 握手挂起等),25 秒也必须杀掉
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(25));
|
||||
|
||||
var errRead = proc.StandardError.ReadToEndAsync(); // 不读干 stderr 会把进程堵死
|
||||
bool timedOut = false;
|
||||
using (timeout.Token.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } }))
|
||||
{
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw; // 用户取消要往上抛,超时杀掉进程则返回原因
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
timedOut = true;
|
||||
}
|
||||
}
|
||||
|
||||
string stderr = string.Empty;
|
||||
try { stderr = await errRead; } catch { /* 进程被杀时读流可能报错,不影响判断 */ }
|
||||
|
||||
if (timedOut)
|
||||
{
|
||||
return new FfmpegRun(true, "抽帧超时(25秒):地址可能已失效或被防盗链卡住");
|
||||
}
|
||||
if (proc.ExitCode != 0)
|
||||
{
|
||||
return new FfmpegRun(false, SummarizeFfmpegError(stderr));
|
||||
}
|
||||
return new FfmpegRun(false, null);
|
||||
}
|
||||
|
||||
/// <summary>取 ffmpeg 报错里最有信息量的一行,太长截断,直接丢给界面显示。</summary>
|
||||
private static string SummarizeFfmpegError(string stderr)
|
||||
{
|
||||
var line = (stderr ?? string.Empty)
|
||||
.Split('\n')
|
||||
.Select(l => l.Trim('\r', ' '))
|
||||
.FirstOrDefault(l => l.Length > 0 && !l.StartsWith("[h264", StringComparison.OrdinalIgnoreCase)
|
||||
&& !l.StartsWith("[aac", StringComparison.OrdinalIgnoreCase));
|
||||
if (string.IsNullOrEmpty(line)) return "ffmpeg 抽帧失败";
|
||||
return line.Length > 90 ? line[..90] + "…" : line;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient()
|
||||
{
|
||||
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
|
||||
|
||||
Reference in new issue
Block a user