This commit is contained in:
owenchen committed 2026-11-06 16:14:51 +08:00
1 parent bcf82d0f45
commit 8260c417e2
7 files changed
+1148 -44

No files matched your search

+179 -2
View File
@@ -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 })