ow
This commit is contained in:
1 parent
bcf82d0f45
commit
8260c417e2
7 files changed
+1148
-44
No files matched your search
Generated
+19
-3
@@ -38,6 +38,7 @@
|
|||||||
addLinkBut = new Button();
|
addLinkBut = new Button();
|
||||||
linkBut = new Button();
|
linkBut = new Button();
|
||||||
dlBut = new Button();
|
dlBut = new Button();
|
||||||
|
listBut = new Button();
|
||||||
statusStrip1 = new StatusStrip();
|
statusStrip1 = new StatusStrip();
|
||||||
statusLbl = new ToolStripStatusLabel();
|
statusLbl = new ToolStripStatusLabel();
|
||||||
progressBar = new ToolStripProgressBar();
|
progressBar = new ToolStripProgressBar();
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
// tableLayoutPanel2
|
// tableLayoutPanel2
|
||||||
//
|
//
|
||||||
tableLayoutPanel2.AutoSize = true;
|
tableLayoutPanel2.AutoSize = true;
|
||||||
tableLayoutPanel2.ColumnCount = 7;
|
tableLayoutPanel2.ColumnCount = 8;
|
||||||
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||||
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
||||||
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
||||||
@@ -88,13 +89,15 @@
|
|||||||
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
||||||
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
||||||
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
||||||
|
tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle());
|
||||||
tableLayoutPanel2.Controls.Add(urlTxt, 0, 0);
|
tableLayoutPanel2.Controls.Add(urlTxt, 0, 0);
|
||||||
tableLayoutPanel2.Controls.Add(goBut, 1, 0);
|
tableLayoutPanel2.Controls.Add(goBut, 1, 0);
|
||||||
tableLayoutPanel2.Controls.Add(displayBut, 5, 0);
|
tableLayoutPanel2.Controls.Add(displayBut, 5, 0);
|
||||||
tableLayoutPanel2.Controls.Add(linkBut, 4, 0);
|
tableLayoutPanel2.Controls.Add(linkBut, 4, 0);
|
||||||
tableLayoutPanel2.Controls.Add(cssBut, 3, 0);
|
tableLayoutPanel2.Controls.Add(cssBut, 3, 0);
|
||||||
tableLayoutPanel2.Controls.Add(addLinkBut, 2, 0);
|
tableLayoutPanel2.Controls.Add(addLinkBut, 2, 0);
|
||||||
tableLayoutPanel2.Controls.Add(dlBut, 6, 0);
|
tableLayoutPanel2.Controls.Add(listBut, 6, 0);
|
||||||
|
tableLayoutPanel2.Controls.Add(dlBut, 7, 0);
|
||||||
tableLayoutPanel2.Dock = DockStyle.Fill;
|
tableLayoutPanel2.Dock = DockStyle.Fill;
|
||||||
tableLayoutPanel2.Location = new Point(3, 3);
|
tableLayoutPanel2.Location = new Point(3, 3);
|
||||||
tableLayoutPanel2.Name = "tableLayoutPanel2";
|
tableLayoutPanel2.Name = "tableLayoutPanel2";
|
||||||
@@ -123,7 +126,7 @@
|
|||||||
//
|
//
|
||||||
// displayBut
|
// displayBut
|
||||||
//
|
//
|
||||||
displayBut.Image =Google_Chrome.Properties.Resources.view_16;
|
displayBut.Image = Google_Chrome.Properties.Resources.view_16;
|
||||||
displayBut.Location = new Point(1387, 3);
|
displayBut.Location = new Point(1387, 3);
|
||||||
displayBut.Name = "displayBut";
|
displayBut.Name = "displayBut";
|
||||||
displayBut.Size = new Size(20, 23);
|
displayBut.Size = new Size(20, 23);
|
||||||
@@ -173,6 +176,18 @@
|
|||||||
dlBut.UseVisualStyleBackColor = true;
|
dlBut.UseVisualStyleBackColor = true;
|
||||||
dlBut.Click += dlBut_Click;
|
dlBut.Click += dlBut_Click;
|
||||||
//
|
//
|
||||||
|
// listBut
|
||||||
|
//
|
||||||
|
listBut.Image = Google_Chrome.Properties.Resources.search_16;
|
||||||
|
listBut.Location = new Point(1382, 3);
|
||||||
|
listBut.Name = "listBut";
|
||||||
|
listBut.Size = new Size(55, 23);
|
||||||
|
listBut.TabIndex = 1;
|
||||||
|
listBut.Text = "列表";
|
||||||
|
listBut.TextImageRelation = TextImageRelation.ImageBeforeText;
|
||||||
|
listBut.UseVisualStyleBackColor = true;
|
||||||
|
listBut.Click += listBut_Click;
|
||||||
|
//
|
||||||
// statusStrip1
|
// statusStrip1
|
||||||
//
|
//
|
||||||
statusStrip1.Items.AddRange(new ToolStripItem[] { statusLbl, progressBar, cancelBut });
|
statusStrip1.Items.AddRange(new ToolStripItem[] { statusLbl, progressBar, cancelBut });
|
||||||
@@ -236,6 +251,7 @@
|
|||||||
private Button addLinkBut;
|
private Button addLinkBut;
|
||||||
private Button linkBut;
|
private Button linkBut;
|
||||||
private Button dlBut;
|
private Button dlBut;
|
||||||
|
private Button listBut;
|
||||||
private StatusStrip statusStrip1;
|
private StatusStrip statusStrip1;
|
||||||
private ToolStripStatusLabel statusLbl;
|
private ToolStripStatusLabel statusLbl;
|
||||||
private ToolStripProgressBar progressBar;
|
private ToolStripProgressBar progressBar;
|
||||||
|
|||||||
+480
-39
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.Web.WebView2.Core;
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
|
||||||
namespace WinFormsApp3
|
namespace WinFormsApp3
|
||||||
@@ -29,6 +30,9 @@ namespace WinFormsApp3
|
|||||||
private CancellationTokenSource? downloadCts;
|
private CancellationTokenSource? downloadCts;
|
||||||
private bool hooksAttached;
|
private bool hooksAttached;
|
||||||
|
|
||||||
|
/// <summary>批量下载时用于识别“用户按了取消”,直接中止后续条目。</summary>
|
||||||
|
private bool downloadWasCanceled;
|
||||||
|
|
||||||
public Form1()
|
public Form1()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -283,6 +287,34 @@ namespace WinFormsApp3
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把 WebView2 调用封送回 UI 线程执行:CoreWebView2 是 COM 接口,
|
||||||
|
/// 从线程池线程(如截图预览的后台任务)直接调会永久卡死,必须先封送。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<T> OnUiAsync<T>(Func<Task<T>> action)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
PreviewLog.Write($"OnUiAsync 排队 {action.Method.Name}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BeginInvoke(new Action(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
T r = await action();
|
||||||
|
PreviewLog.Write($"OnUiAsync 完成 {action.Method.Name}");
|
||||||
|
tcs.SetResult(r);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { tcs.SetException(ex); }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
tcs.SetException(ex);
|
||||||
|
}
|
||||||
|
return await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 嗅探到的地址可能来自不同 iframe,列表要加锁并限制长度,
|
/// 嗅探到的地址可能来自不同 iframe,列表要加锁并限制长度,
|
||||||
/// 只留最近若干个,供“当前播放地址取不到时”兜底用。
|
/// 只留最近若干个,供“当前播放地址取不到时”兜底用。
|
||||||
@@ -434,6 +466,300 @@ namespace WinFormsApp3
|
|||||||
await DownloadCurrentVideoAsync();
|
await DownloadCurrentVideoAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void listBut_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
await ShowVideoListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 打开“本页视频列表”:列出全部候选并生成截图预览(每个视频 3 张、间隔 5 秒),
|
||||||
|
/// 用户对比确认哪个是当前视频后勾选,选文件夹批量下载。
|
||||||
|
/// </summary>
|
||||||
|
private async Task ShowVideoListAsync()
|
||||||
|
{
|
||||||
|
if (webView21.CoreWebView2 == null)
|
||||||
|
{
|
||||||
|
SetStatus("页面还没加载好,请稍候");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadCts != null)
|
||||||
|
{
|
||||||
|
SetStatus("已有下载在进行中,可先点“取消下载”");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listBut.Enabled = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SetStatus("正在识别本页视频…");
|
||||||
|
string raw = await webView21.CoreWebView2.ExecuteScriptAsync(BuildGrabScript());
|
||||||
|
(List<VideoCandidate> items, HashSet<string> currentUrls) = CollectCandidates(raw);
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
SetStatus("没识别到视频地址");
|
||||||
|
MessageBox.Show(this,
|
||||||
|
"本页没有识别到可下载的视频地址。\n\n" +
|
||||||
|
"常见原因:\r\n1. 视频还没真正开始播放,请先点播放再点本按钮;\r\n"
|
||||||
|
+ "2. 站点用了加密流(DRM),无法直接下载;\r\n"
|
||||||
|
+ "3. 视频在 iframe 里且尚未加载完,稍等一两秒重试。",
|
||||||
|
"下载列表", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键:列表窗口是模态对话框,实测对话框存在期间 WebView2 的异步回调
|
||||||
|
// (ExecuteScriptAsync / GetCookiesAsync)不会送达,要等窗口关闭才一次性返回。
|
||||||
|
// 所以所有依赖 WebView2 的数据必须在 ShowDialog 之前预取完毕,
|
||||||
|
// 后台预览任务只做 ffmpeg 抽帧,不再碰 WebView2。
|
||||||
|
SetStatus("正在取登录态与播放器画面…");
|
||||||
|
Dictionary<string, string?> cookies = new Dictionary<string, string?>();
|
||||||
|
foreach (VideoCandidate item in items)
|
||||||
|
{
|
||||||
|
if (!cookies.ContainsKey(item.Url))
|
||||||
|
{
|
||||||
|
cookies[item.Url] = await GetCookieHeaderAsync(item.Url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<byte[]> canvasFrames = await CaptureCurrentFramesAsync(CancellationToken.None);
|
||||||
|
Image? currentStrip = VideoListForm.ComposeFrames(canvasFrames);
|
||||||
|
PreviewLog.Write($"预取完成 cookies={cookies.Count} canvas帧={canvasFrames.Count} strip={(currentStrip != null ? "ok" : "null")}");
|
||||||
|
|
||||||
|
SetStatus($"识别到 {items.Count} 个视频,正在生成截图…");
|
||||||
|
Func<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> factory =
|
||||||
|
(c, ct) => BuildPreviewAsync(c, currentUrls.Contains(c.Url), currentStrip, cookies, ct);
|
||||||
|
using VideoListForm form = new VideoListForm(items, currentUrls, factory);
|
||||||
|
if (form.ShowDialog(this) != DialogResult.OK || form.SelectedCandidates.Count == 0)
|
||||||
|
{
|
||||||
|
SetStatus("已取消");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using FolderBrowserDialog folderDlg = new FolderBrowserDialog
|
||||||
|
{
|
||||||
|
Description = $"选择保存文件夹(将下载 {form.SelectedCandidates.Count} 个视频)"
|
||||||
|
};
|
||||||
|
if (folderDlg.ShowDialog(this) != DialogResult.OK)
|
||||||
|
{
|
||||||
|
SetStatus("已取消");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DownloadCandidatesBatchAsync(form.SelectedCandidates, folderDlg.SelectedPath);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
SetStatus("已取消");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
SetStatus($"识别失败:{ex.Message}");
|
||||||
|
MessageBox.Show(this, ex.Message, "下载列表", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
listBut.Enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 给列表行生成截图预览。当前正在播的视频直接用开窗前从播放器画面预取好的截图条
|
||||||
|
/// (不受防盗链和地址过期影响);其余候选用 ffmpeg 带预取的 Referer/Cookie 读远端地址抽帧。
|
||||||
|
/// 这个方法在后台线程上跑,绝不能调 WebView2 API(模态对话框期间回调不会送达)。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(Image? Image, string? Error)> BuildPreviewAsync(
|
||||||
|
VideoCandidate candidate, bool isCurrent, Image? currentStrip,
|
||||||
|
IReadOnlyDictionary<string, string?> cookies, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string shortUrl = candidate.Url.Length > 70 ? candidate.Url[..70] : candidate.Url;
|
||||||
|
PreviewLog.Write($"预览开始 isCurrent={isCurrent} url={shortUrl}");
|
||||||
|
|
||||||
|
// 只在内存里的播放列表文本没有可 seek 的地址,不抽帧
|
||||||
|
if (candidate.PlaylistText != null)
|
||||||
|
{
|
||||||
|
return (null, "内存播放列表,无法抽帧预览");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCurrent && currentStrip != null)
|
||||||
|
{
|
||||||
|
PreviewLog.Write("预览成功:播放器画面预取帧");
|
||||||
|
return (currentStrip, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? referer = candidate.Referer ?? RefererFor(candidate.Url, null);
|
||||||
|
cookies.TryGetValue(candidate.Url, out string? cookie);
|
||||||
|
VideoDownloader downloader = new VideoDownloader(referer, cookie);
|
||||||
|
PreviewLog.Write("ffmpeg 抽帧开始");
|
||||||
|
VideoDownloader.FrameExtraction result = await downloader.ExtractFramesAsync(candidate.Url, ct);
|
||||||
|
Image? img = VideoListForm.ComposeFrames(result.Frames);
|
||||||
|
PreviewLog.Write($"ffmpeg 抽帧结束 frames={result.Frames.Count} error={result.Error ?? "-"}");
|
||||||
|
return img != null ? (img, null) : (null, result.Error ?? "没抽到帧");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
PreviewLog.Write("预览被取消");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
PreviewLog.Write($"预览异常 {ex.GetType().Name}: {ex.Message}");
|
||||||
|
return (null, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从正在播放的 <video> 元素里截 3 帧(2/7/12 秒,对应“每 5 秒 3 张”):
|
||||||
|
/// 临时暂停→逐帧 seek+canvas 绘制→恢复原进度与播放状态;单次 seek 限 3 秒,
|
||||||
|
/// 遇到 tainted canvas(跨域污染)等异常就放弃,退回 ffmpeg 远程抽帧。
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<byte[]>> CaptureCurrentFramesAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
List<byte[]> frames = new List<byte[]>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (webView21.CoreWebView2 == null) return frames;
|
||||||
|
|
||||||
|
// 后台线程不能直接碰 ExecuteScriptAsync,封送回 UI 线程;
|
||||||
|
// 整条脚本再加 20 秒硬超时,页面播放器抽风时不能把预览任务永久挂住
|
||||||
|
using var overall = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
overall.CancelAfter(TimeSpan.FromSeconds(20));
|
||||||
|
string raw;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
raw = await OnUiAsync(() => webView21.CoreWebView2!.ExecuteScriptAsync(CurrentFramesScript))
|
||||||
|
.WaitAsync(overall.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
|
||||||
|
{
|
||||||
|
PreviewLog.Write($"播放器取帧脚本超时/取消:{ex.GetType().Name}");
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
PreviewLog.Write($"播放器取帧脚本返回 {raw.Length} 字符");
|
||||||
|
string? inner = JsonSerializer.Deserialize<string>(raw);
|
||||||
|
if (string.IsNullOrEmpty(inner)) return frames;
|
||||||
|
|
||||||
|
if (JsonNode.Parse(inner)?["frames"] is JsonArray arr)
|
||||||
|
{
|
||||||
|
foreach (JsonNode? item in arr)
|
||||||
|
{
|
||||||
|
string? dataUrl = item?.GetValue<string>();
|
||||||
|
int comma = dataUrl?.IndexOf(',') ?? -1;
|
||||||
|
if (dataUrl == null || comma < 0) continue;
|
||||||
|
try { frames.Add(Convert.FromBase64String(dataUrl[(comma + 1)..])); }
|
||||||
|
catch { /* 坏帧跳过 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 播放器截图失败就退回 ffmpeg 远程抽帧
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string CurrentFramesScript = @"
|
||||||
|
(async function(){
|
||||||
|
function findVideo(){
|
||||||
|
var vids=Array.prototype.slice.call(document.querySelectorAll('video'));
|
||||||
|
for(var i=0;i<vids.length;i++){ if(!vids[i].paused && vids[i].currentTime>0) return vids[i]; }
|
||||||
|
for(var j=0;j<vids.length;j++){ if(vids[j].videoWidth>0) return vids[j]; }
|
||||||
|
return vids[0]||null;
|
||||||
|
}
|
||||||
|
var v=findVideo();
|
||||||
|
if(!v) return JSON.stringify({frames:[]});
|
||||||
|
var times=[2,7,12], frames=[], wasPaused=v.paused, t0=v.currentTime;
|
||||||
|
try{
|
||||||
|
v.pause();
|
||||||
|
for(var k=0;k<times.length;k++){
|
||||||
|
var t=times[k];
|
||||||
|
if(isFinite(v.duration) && t>v.duration-0.2) t=v.duration>1?v.duration-0.5:0;
|
||||||
|
var ok=true;
|
||||||
|
try{
|
||||||
|
await new Promise(function(res,rej){
|
||||||
|
var done=false;
|
||||||
|
var to=setTimeout(function(){ if(!done){done=true;rej('t');} },3000);
|
||||||
|
v.onseeked=function(){ if(!done){done=true;clearTimeout(to);res();} };
|
||||||
|
try{ v.currentTime=t; }catch(e){ if(!done){done=true;clearTimeout(to);rej('s');} }
|
||||||
|
});
|
||||||
|
}catch(e){ ok=false; }
|
||||||
|
if(!ok) break;
|
||||||
|
try{
|
||||||
|
var c=document.createElement('canvas');
|
||||||
|
var w=v.videoWidth||480, h=v.videoHeight||270;
|
||||||
|
var sc=Math.min(1,480/w);
|
||||||
|
c.width=Math.round(w*sc); c.height=Math.round(h*sc);
|
||||||
|
c.getContext('2d').drawImage(v,0,0,c.width,c.height);
|
||||||
|
frames.push(c.toDataURL('image/jpeg',0.85));
|
||||||
|
}catch(e){ frames=[]; break; }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 注意:这里绝不能 await v.play()——抖音播放器会干预 play 的 Promise,
|
||||||
|
// 一旦 pending 不结束整段脚本永不返回,截图任务会永久挂死
|
||||||
|
try{
|
||||||
|
v.currentTime=t0;
|
||||||
|
if(!wasPaused){ try{ v.play().catch(function(){}); }catch(e){} }
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
return JSON.stringify({frames:frames});
|
||||||
|
})()";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量下载:逐个存进所选文件夹,音视频分离的轨对仍走精确合并;
|
||||||
|
/// 某条失败时询问是否继续,用户点取消则中止剩余条目。
|
||||||
|
/// </summary>
|
||||||
|
private async Task DownloadCandidatesBatchAsync(List<VideoCandidate> items, string folder)
|
||||||
|
{
|
||||||
|
int succeeded = 0;
|
||||||
|
List<string> failures = new List<string>();
|
||||||
|
VideoDownloader namer = new VideoDownloader(RefererFor(items[0].Url, webView21.CoreWebView2?.Source));
|
||||||
|
|
||||||
|
for (int i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
VideoCandidate item = items[i];
|
||||||
|
string fileName = namer.SuggestedFileName(item.Url, PageTitle);
|
||||||
|
string filePath = UniqueFilePath(folder, fileName);
|
||||||
|
|
||||||
|
SetStatus($"批量下载 {i + 1}/{items.Count}:{fileName}");
|
||||||
|
downloadWasCanceled = false;
|
||||||
|
string? failure = await TryDownloadAsync(item, filePath, promptOpenFolder: false);
|
||||||
|
|
||||||
|
if (downloadWasCanceled) break; // 用户取消整个批次
|
||||||
|
|
||||||
|
if (failure == null)
|
||||||
|
{
|
||||||
|
succeeded++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failures.Add($"{fileName}:{failure}");
|
||||||
|
if (i < items.Count - 1 && MessageBox.Show(this,
|
||||||
|
$"第 {i + 1} 个下载失败:\n{failure}\n\n跳过它继续下载剩下的吗?",
|
||||||
|
"批量下载", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string summary = $"成功 {succeeded} 个,失败 {failures.Count} 个(共 {items.Count})。";
|
||||||
|
SetStatus($"批量下载完成:{summary}");
|
||||||
|
if (failures.Count > 0)
|
||||||
|
{
|
||||||
|
summary += "\n\n" + string.Join("\n", failures.Take(8)) + (failures.Count > 8 ? "\n…" : string.Empty);
|
||||||
|
}
|
||||||
|
MessageBox.Show(this, summary, "批量下载", MessageBoxButtons.OK,
|
||||||
|
failures.Count == 0 ? MessageBoxIcon.Information : MessageBoxIcon.Warning);
|
||||||
|
|
||||||
|
if (succeeded > 0) OpenFolder(folder);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 只下载当前正在播放的那一个视频地址,不再列出一堆候选让用户挑。
|
/// 只下载当前正在播放的那一个视频地址,不再列出一堆候选让用户挑。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -512,40 +838,13 @@ namespace WinFormsApp3
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private VideoCandidate? CollectCurrentCandidate(string grabResultJson)
|
private VideoCandidate? CollectCurrentCandidate(string grabResultJson)
|
||||||
{
|
{
|
||||||
string current = string.Empty;
|
GrabInfo g = ParseGrab(grabResultJson);
|
||||||
string pageUrl = string.Empty;
|
string pageUrl = g.PageUrl;
|
||||||
string dashVideo = string.Empty;
|
List<string> sniffed = g.Sniffed;
|
||||||
string dashAudio = string.Empty;
|
string dashVideo = g.DashVideo;
|
||||||
string videoId = string.Empty;
|
string dashAudio = g.DashAudio;
|
||||||
List<string> sniffed = new List<string>();
|
string videoId = g.VideoId;
|
||||||
|
string current = g.Current;
|
||||||
try
|
|
||||||
{
|
|
||||||
// ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号
|
|
||||||
string? inner = JsonSerializer.Deserialize<string>(grabResultJson);
|
|
||||||
if (!string.IsNullOrEmpty(inner))
|
|
||||||
{
|
|
||||||
JsonNode? node = JsonNode.Parse(inner);
|
|
||||||
current = node?["current"]?.GetValue<string>() ?? string.Empty;
|
|
||||||
pageUrl = node?["pageUrl"]?.GetValue<string>() ?? string.Empty;
|
|
||||||
dashVideo = node?["dashVideo"]?.GetValue<string>() ?? string.Empty;
|
|
||||||
dashAudio = node?["dashAudio"]?.GetValue<string>() ?? string.Empty;
|
|
||||||
videoId = node?["videoId"]?.GetValue<string>() ?? string.Empty;
|
|
||||||
|
|
||||||
if (node?["sniffed"] is JsonArray arr)
|
|
||||||
{
|
|
||||||
foreach (JsonNode? item in arr)
|
|
||||||
{
|
|
||||||
string? u = item?.GetValue<string>();
|
|
||||||
if (!string.IsNullOrEmpty(u)) sniffed.Add(u);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
|
|
||||||
}
|
|
||||||
|
|
||||||
// 第一层:video 标签自己的播放源
|
// 第一层:video 标签自己的播放源
|
||||||
if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current))
|
if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current))
|
||||||
@@ -594,6 +893,125 @@ namespace WinFormsApp3
|
|||||||
return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl);
|
return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 抓取脚本回包的解析结果,当前下载与列表两条路径共用。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class GrabInfo
|
||||||
|
{
|
||||||
|
public string Current = string.Empty;
|
||||||
|
public string PageUrl = string.Empty;
|
||||||
|
public string DashVideo = string.Empty;
|
||||||
|
public string DashAudio = string.Empty;
|
||||||
|
public string VideoId = string.Empty;
|
||||||
|
public List<string> Sniffed = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GrabInfo ParseGrab(string grabResultJson)
|
||||||
|
{
|
||||||
|
GrabInfo g = new GrabInfo();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号
|
||||||
|
string? inner = JsonSerializer.Deserialize<string>(grabResultJson);
|
||||||
|
if (!string.IsNullOrEmpty(inner))
|
||||||
|
{
|
||||||
|
JsonNode? node = JsonNode.Parse(inner);
|
||||||
|
g.Current = node?["current"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
g.PageUrl = node?["pageUrl"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
g.DashVideo = node?["dashVideo"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
g.DashAudio = node?["dashAudio"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
g.VideoId = node?["videoId"]?.GetValue<string>() ?? string.Empty;
|
||||||
|
|
||||||
|
if (node?["sniffed"] is JsonArray arr)
|
||||||
|
{
|
||||||
|
foreach (JsonNode? item in arr)
|
||||||
|
{
|
||||||
|
string? u = item?.GetValue<string>();
|
||||||
|
if (!string.IsNullOrEmpty(u)) g.Sniffed.Add(u!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 页面没有返回可解析结果时,只用宿主侧嗅探到的地址
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 给“列表”按钮收集本页全部候选:当前播放源、DASH 视频轨排最前并标记为当前,
|
||||||
|
/// 其余嗅探到的整片地址按新旧倒序追加;每条都尝试配对同组音频轨,下载时精确合并。
|
||||||
|
/// </summary>
|
||||||
|
private (List<VideoCandidate> Items, HashSet<string> CurrentUrls) CollectCandidates(string grabResultJson)
|
||||||
|
{
|
||||||
|
GrabInfo g = ParseGrab(grabResultJson);
|
||||||
|
List<VideoCandidate> items = new List<VideoCandidate>();
|
||||||
|
HashSet<string> currentUrls = new HashSet<string>();
|
||||||
|
|
||||||
|
void Add(string? url, string? audio, bool isCurrent)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(url)
|
||||||
|
|| !VideoDownloader.LooksLikeMedia(url)
|
||||||
|
|| VideoDownloader.IsSegmentUrl(url)
|
||||||
|
|| VideoDownloader.IsDashAudioOnly(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (items.Any(i => i.Url == url))
|
||||||
|
{
|
||||||
|
if (isCurrent) currentUrls.Add(url!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有 DASH 纯视频轨才需要配音频轨;普通整片直链自带音轨,乱配反而会把别人的声音合进去
|
||||||
|
string? pairedAudio = audio ?? (VideoDownloader.IsDashVideoOnly(url)
|
||||||
|
? FindPairedAudio(url!, isCurrent ? g.VideoId : IdFromUrl(url!))
|
||||||
|
: null);
|
||||||
|
items.Add(new VideoCandidate(url, null, RefererFor(url, g.PageUrl), pairedAudio));
|
||||||
|
if (isCurrent) currentUrls.Add(url!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前播放的两条源排最前,方便和下面的嗅探条目肉眼对比
|
||||||
|
Add(g.Current, null, true);
|
||||||
|
Add(g.DashVideo, string.IsNullOrEmpty(g.DashAudio) ? null : g.DashAudio, true);
|
||||||
|
|
||||||
|
List<string> pool = g.Sniffed.Concat(SnapshotSniffedUrls())
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 有视频 ID 时先把同 ID 的地址排前面,它们多半就是当前视频
|
||||||
|
if (!string.IsNullOrEmpty(g.VideoId))
|
||||||
|
{
|
||||||
|
pool = pool.OrderByDescending(u => u.Contains(g.VideoId)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 嗅探列表里越新的越接近当前视频,倒序追加
|
||||||
|
for (int i = pool.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
string url = pool[i];
|
||||||
|
Add(url, null, !string.IsNullOrEmpty(g.VideoId) && url.Contains(g.VideoId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (items, currentUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从地址里提视频 ID(抖音这类地址路径里带 10 位以上纯数字),用于音频轨配对。
|
||||||
|
/// </summary>
|
||||||
|
private static string IdFromUrl(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Match m = Regex.Match(new Uri(url).AbsolutePath, @"\d{10,}");
|
||||||
|
return m.Success ? m.Value : string.Empty;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 从已嗅探地址里找和视频轨同组的 DASH 音频轨。
|
/// 从已嗅探地址里找和视频轨同组的 DASH 音频轨。
|
||||||
/// 有 videoId 时优先选 URL 里包含该 ID 的音频轨,否则取最后一条 media-audio。
|
/// 有 videoId 时优先选 URL 里包含该 ID 的音频轨,否则取最后一条 media-audio。
|
||||||
@@ -676,8 +1094,9 @@ namespace WinFormsApp3
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 执行一次下载。返回 null 表示成功或用户主动取消,否则返回错误信息给上层提示。
|
/// 执行一次下载。返回 null 表示成功或用户主动取消,否则返回错误信息给上层提示。
|
||||||
|
/// 批量下载时 promptOpenFolder 传 false,避免每条都弹“打开文件夹”。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<string?> TryDownloadAsync(VideoCandidate candidate, string filePath)
|
private async Task<string?> TryDownloadAsync(VideoCandidate candidate, string filePath, bool promptOpenFolder = true)
|
||||||
{
|
{
|
||||||
VideoDownloader downloader = await CreateDownloaderAsync(candidate);
|
VideoDownloader downloader = await CreateDownloaderAsync(candidate);
|
||||||
|
|
||||||
@@ -706,7 +1125,7 @@ namespace WinFormsApp3
|
|||||||
|
|
||||||
SetStatus($"已下载 {Path.GetFileName(result.FilePath)}({VideoDownloader.FormatSize(result.Bytes)})");
|
SetStatus($"已下载 {Path.GetFileName(result.FilePath)}({VideoDownloader.FormatSize(result.Bytes)})");
|
||||||
|
|
||||||
if (MessageBox.Show(this, $"视频已保存到:\n{result.FilePath}\n\n要打开所在文件夹吗?",
|
if (promptOpenFolder && MessageBox.Show(this, $"视频已保存到:\n{result.FilePath}\n\n要打开所在文件夹吗?",
|
||||||
"下载完成", MessageBoxButtons.YesNo, MessageBoxIcon.Information) == DialogResult.Yes)
|
"下载完成", MessageBoxButtons.YesNo, MessageBoxIcon.Information) == DialogResult.Yes)
|
||||||
{
|
{
|
||||||
OpenFolder(result.FilePath);
|
OpenFolder(result.FilePath);
|
||||||
@@ -716,7 +1135,8 @@ namespace WinFormsApp3
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// 用户主动取消就别再换地址重试了
|
// 用户主动取消就别再换地址重试了;批量路径靠这个标志中止剩余条目
|
||||||
|
downloadWasCanceled = true;
|
||||||
SetStatus("已取消下载");
|
SetStatus("已取消下载");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -745,7 +1165,11 @@ namespace WinFormsApp3
|
|||||||
return new VideoDownloader(referer, cookie);
|
return new VideoDownloader(referer, cookie);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> GetCookieHeaderAsync(string url)
|
/// <summary>
|
||||||
|
/// 取 Cookie 也是 CoreWebView2 COM 调用,同样封送回 UI 线程,
|
||||||
|
/// 否则截图预览的后台任务会在这里永久卡死。
|
||||||
|
/// </summary>
|
||||||
|
private Task<string?> GetCookieHeaderAsync(string url) => OnUiAsync(async () =>
|
||||||
{
|
{
|
||||||
CoreWebView2? core = webView21.CoreWebView2;
|
CoreWebView2? core = webView21.CoreWebView2;
|
||||||
if (core == null || !Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) return null;
|
if (core == null || !Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) return null;
|
||||||
@@ -763,7 +1187,7 @@ namespace WinFormsApp3
|
|||||||
// 拿不到 Cookie 就按匿名请求下载
|
// 拿不到 Cookie 就按匿名请求下载
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
private void ReportProgress(DownloadProgress value)
|
private void ReportProgress(DownloadProgress value)
|
||||||
{
|
{
|
||||||
@@ -801,6 +1225,23 @@ namespace WinFormsApp3
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量下载时同页多个候选可能推断出同名文件,重名时加序号避免互相覆盖。
|
||||||
|
/// </summary>
|
||||||
|
private static string UniqueFilePath(string folder, string fileName)
|
||||||
|
{
|
||||||
|
string path = Path.Combine(folder, fileName);
|
||||||
|
if (!File.Exists(path)) return path;
|
||||||
|
|
||||||
|
string name = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
string ext = Path.GetExtension(fileName);
|
||||||
|
for (int i = 2; ; i++)
|
||||||
|
{
|
||||||
|
path = Path.Combine(folder, $"{name} ({i}){ext}");
|
||||||
|
if (!File.Exists(path)) return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void OpenFolder(string filePath)
|
private static void OpenFolder(string filePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace WinFormsApp3
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 截图预览诊断日志:exe 目录下 preview_debug.log。
|
||||||
|
/// 挂起类问题光看界面定位不到,必须把每一步(含线程号)落到磁盘上。
|
||||||
|
/// </summary>
|
||||||
|
public static class PreviewLog
|
||||||
|
{
|
||||||
|
private static readonly object Gate = new();
|
||||||
|
private static readonly string FilePath = Path.Combine(AppContext.BaseDirectory, "preview_debug.log");
|
||||||
|
|
||||||
|
public static void Write(string message)
|
||||||
|
{
|
||||||
|
string line = $"{DateTime.Now:HH:mm:ss.fff} [T{Thread.CurrentThread.ManagedThreadId:D2}] {message}";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (Gate) File.AppendAllText(FilePath, line + Environment.NewLine);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 日志写失败不影响功能
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -265,11 +265,24 @@ namespace WinFormsApp3
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4(直接 copy 不重编码)。
|
/// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4。
|
||||||
|
/// 优先两条流直接 copy:不重编码、时间戳原样保留,音画对齐最准确;
|
||||||
|
/// 只有音频轨格式装不进 mp4 容器时才回退到 AAC 转码。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<bool> MuxDashAsync(string ffmpeg, string videoFile, string audioFile, string outputFile, CancellationToken ct)
|
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
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = ffmpeg,
|
FileName = ffmpeg,
|
||||||
@@ -291,6 +304,170 @@ namespace WinFormsApp3
|
|||||||
return proc.ExitCode == 0 && File.Exists(outputFile) && new FileInfo(outputFile).Length > 0;
|
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()
|
private HttpClient CreateClient()
|
||||||
{
|
{
|
||||||
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
|
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
namespace WinFormsApp3
|
||||||
|
{
|
||||||
|
partial class VideoListForm
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null;
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
components?.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
components = new System.ComponentModel.Container();
|
||||||
|
pnlVideos = new Panel();
|
||||||
|
panelBottom = new Panel();
|
||||||
|
lblHint = new Label();
|
||||||
|
flowButtons = new FlowLayoutPanel();
|
||||||
|
btnDownload = new Button();
|
||||||
|
btnCancel = new Button();
|
||||||
|
SuspendLayout();
|
||||||
|
//
|
||||||
|
// pnlVideos
|
||||||
|
//
|
||||||
|
pnlVideos.AutoScroll = true;
|
||||||
|
pnlVideos.Dock = DockStyle.Fill;
|
||||||
|
pnlVideos.Name = "pnlVideos";
|
||||||
|
//
|
||||||
|
// panelBottom
|
||||||
|
//
|
||||||
|
panelBottom.Controls.Add(flowButtons);
|
||||||
|
panelBottom.Controls.Add(lblHint);
|
||||||
|
panelBottom.Dock = DockStyle.Bottom;
|
||||||
|
panelBottom.Height = 48;
|
||||||
|
panelBottom.Name = "panelBottom";
|
||||||
|
//
|
||||||
|
// lblHint
|
||||||
|
//
|
||||||
|
lblHint.AutoSize = true;
|
||||||
|
lblHint.Location = new Point(12, 15);
|
||||||
|
lblHint.Name = "lblHint";
|
||||||
|
lblHint.Text = "正在生成预览截图…";
|
||||||
|
//
|
||||||
|
// flowButtons
|
||||||
|
// 用从右往左的流式布局放按钮,避免 Anchor+固定坐标在初始化时被锚点公式推出窗外
|
||||||
|
//
|
||||||
|
flowButtons.Controls.Add(btnCancel);
|
||||||
|
flowButtons.Controls.Add(btnDownload);
|
||||||
|
flowButtons.Dock = DockStyle.Right;
|
||||||
|
flowButtons.FlowDirection = FlowDirection.RightToLeft;
|
||||||
|
flowButtons.Location = new Point(600, 0);
|
||||||
|
flowButtons.Name = "flowButtons";
|
||||||
|
flowButtons.Size = new Size(300, 48);
|
||||||
|
flowButtons.WrapContents = false;
|
||||||
|
//
|
||||||
|
// btnDownload
|
||||||
|
//
|
||||||
|
btnDownload.Margin = new Padding(0, 9, 8, 9);
|
||||||
|
btnDownload.Name = "btnDownload";
|
||||||
|
btnDownload.Size = new Size(150, 30);
|
||||||
|
btnDownload.Text = "下载选中 (0)";
|
||||||
|
btnDownload.UseVisualStyleBackColor = true;
|
||||||
|
btnDownload.Click += btnDownload_Click;
|
||||||
|
//
|
||||||
|
// btnCancel
|
||||||
|
//
|
||||||
|
btnCancel.DialogResult = DialogResult.Cancel;
|
||||||
|
btnCancel.Margin = new Padding(0, 9, 4, 9);
|
||||||
|
btnCancel.Name = "btnCancel";
|
||||||
|
btnCancel.Size = new Size(88, 30);
|
||||||
|
btnCancel.Text = "取消";
|
||||||
|
btnCancel.UseVisualStyleBackColor = true;
|
||||||
|
//
|
||||||
|
// VideoListForm
|
||||||
|
//
|
||||||
|
CancelButton = btnCancel;
|
||||||
|
AcceptButton = btnDownload;
|
||||||
|
ClientSize = new Size(900, 640);
|
||||||
|
Controls.Add(pnlVideos);
|
||||||
|
Controls.Add(panelBottom);
|
||||||
|
MinimumSize = new Size(640, 400);
|
||||||
|
Name = "VideoListForm";
|
||||||
|
StartPosition = FormStartPosition.CenterParent;
|
||||||
|
Text = "本页视频列表(截图预览,勾选后批量下载)";
|
||||||
|
panelBottom.ResumeLayout(false);
|
||||||
|
panelBottom.PerformLayout();
|
||||||
|
ResumeLayout(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Panel pnlVideos;
|
||||||
|
private Panel panelBottom;
|
||||||
|
private Label lblHint;
|
||||||
|
private FlowLayoutPanel flowButtons;
|
||||||
|
private Button btnDownload;
|
||||||
|
private Button btnCancel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
|
||||||
|
namespace WinFormsApp3
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 本页视频下载列表:每个候选视频用 ffmpeg 抽 3 张间隔 5 秒的截图拼成预览条,
|
||||||
|
/// 肉眼对比哪个是当前视频后勾选,选文件夹批量下载。
|
||||||
|
/// 当前正在播放的那条默认勾上。
|
||||||
|
/// </summary>
|
||||||
|
public partial class VideoListForm : Form
|
||||||
|
{
|
||||||
|
private const int RowHeight = 124;
|
||||||
|
private const int PreviewWidth = 496;
|
||||||
|
private const int PreviewHeight = 100;
|
||||||
|
|
||||||
|
private readonly Func<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> _previewFactory;
|
||||||
|
private readonly List<VideoRow> _rows = new();
|
||||||
|
private readonly CancellationTokenSource _loadCts = new();
|
||||||
|
private int _generatedCount;
|
||||||
|
private int _failedCount;
|
||||||
|
|
||||||
|
/// <summary>点“下载选中”后带回的勾选候选(按列表顺序)。</summary>
|
||||||
|
public List<VideoCandidate> SelectedCandidates { get; } = new();
|
||||||
|
|
||||||
|
public VideoListForm(IReadOnlyList<VideoCandidate> candidates, IReadOnlyCollection<string> currentUrls,
|
||||||
|
Func<VideoCandidate, CancellationToken, Task<(Image? Image, string? Error)>> previewFactory)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
_previewFactory = previewFactory;
|
||||||
|
BuildRows(candidates, currentUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildRows(IReadOnlyList<VideoCandidate> candidates, IReadOnlyCollection<string> currentUrls)
|
||||||
|
{
|
||||||
|
int y = 8;
|
||||||
|
foreach (VideoCandidate candidate in candidates)
|
||||||
|
{
|
||||||
|
bool isCurrent = currentUrls.Contains(candidate.Url);
|
||||||
|
VideoRow row = new VideoRow(candidate, Describe(candidate, isCurrent), isCurrent)
|
||||||
|
{
|
||||||
|
Location = new Point(8, y),
|
||||||
|
Width = Math.Max(620, pnlVideos.ClientSize.Width - 24),
|
||||||
|
};
|
||||||
|
row.CheckChanged += (_, _) => UpdateSelectionCount();
|
||||||
|
_rows.Add(row);
|
||||||
|
pnlVideos.Controls.Add(row);
|
||||||
|
y += RowHeight + 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
pnlVideos.Resize += (_, _) =>
|
||||||
|
{
|
||||||
|
foreach (VideoRow row in _rows)
|
||||||
|
{
|
||||||
|
row.Width = Math.Max(620, pnlVideos.ClientSize.Width - 24);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
UpdateSelectionCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 截图预览在后台生成(三条并发),生成一条刷新一条;
|
||||||
|
/// 失败时把原因直接写在预览条上,不让界面一直停在“生成中…”。
|
||||||
|
/// </summary>
|
||||||
|
protected override async void OnShown(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnShown(e);
|
||||||
|
|
||||||
|
if (_rows.Count == 0)
|
||||||
|
{
|
||||||
|
lblHint.Text = "本页没有可下载的视频";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SemaphoreSlim semaphore = new SemaphoreSlim(3);
|
||||||
|
Task[] tasks = _rows.Select(async row =>
|
||||||
|
{
|
||||||
|
await semaphore.WaitAsync(_loadCts.Token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Image? preview = null;
|
||||||
|
string? error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(preview, error) = await _previewFactory(row.Candidate, _loadCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
PreviewLog.Write("预览任务被取消(列表已关闭)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 抽帧失败按“无预览”处理,勾选与下载不受影响
|
||||||
|
error = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
PreviewLog.Write($"预览回传 UI img={(preview != null ? "ok" : "null")} error={(error ?? "-")}");
|
||||||
|
|
||||||
|
if (_loadCts.IsCancellationRequested || !IsHandleCreated || IsDisposed)
|
||||||
|
{
|
||||||
|
preview?.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BeginInvoke(() =>
|
||||||
|
{
|
||||||
|
row.SetResult(preview, error);
|
||||||
|
if (preview == null) _failedCount++;
|
||||||
|
_generatedCount++;
|
||||||
|
// 最后一行刷新时直接切到完成文案,不依赖 WhenAll 后续代码的时序
|
||||||
|
lblHint.Text = _generatedCount < _rows.Count
|
||||||
|
? $"截图生成中… {_generatedCount}/{_rows.Count}" +
|
||||||
|
(_failedCount > 0 ? $"({_failedCount} 条失败,原因见预览条)" : string.Empty)
|
||||||
|
: (_failedCount == 0
|
||||||
|
? "截图已生成,对比后勾选要下载的视频"
|
||||||
|
: $"截图生成完毕({_failedCount} 条失败,原因见预览条;不影响勾选下载)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
semaphore.Release();
|
||||||
|
}
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
if (!IsDisposed && !_loadCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
lblHint.Text = _failedCount == 0
|
||||||
|
? "截图已生成,对比后勾选要下载的视频"
|
||||||
|
: $"截图生成完毕({_failedCount} 条失败,原因见预览条;不影响勾选下载)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnFormClosed(FormClosedEventArgs e)
|
||||||
|
{
|
||||||
|
_loadCts.Cancel();
|
||||||
|
_loadCts.Dispose();
|
||||||
|
base.OnFormClosed(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateSelectionCount()
|
||||||
|
{
|
||||||
|
btnDownload.Text = $"下载选中 ({_rows.Count(r => r.IsChecked)})";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void btnDownload_Click(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
SelectedCandidates.Clear();
|
||||||
|
SelectedCandidates.AddRange(_rows.Where(r => r.IsChecked).Select(r => r.Candidate));
|
||||||
|
|
||||||
|
if (SelectedCandidates.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "先勾选要下载的视频(看截图对比哪个是当前视频)。",
|
||||||
|
"下载列表", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogResult = DialogResult.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Describe(VideoCandidate candidate, bool isCurrent)
|
||||||
|
{
|
||||||
|
List<string> tags = new List<string>();
|
||||||
|
if (isCurrent) tags.Add("当前播放");
|
||||||
|
if (candidate.PlaylistText != null) tags.Add("内存播放列表");
|
||||||
|
else if (VideoDownloader.IsHlsUrl(candidate.Url)) tags.Add("m3u8");
|
||||||
|
if (!string.IsNullOrEmpty(candidate.AudioUrl)) tags.Add("音频轨已配对·自动合并");
|
||||||
|
if (tags.Count == 0) tags.Add("直链");
|
||||||
|
|
||||||
|
string hostLine;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Uri u = new Uri(candidate.Url);
|
||||||
|
string last = Path.GetFileName(u.AbsolutePath);
|
||||||
|
hostLine = last.Length > 0 ? $"{u.Host} … {last}" : u.Host;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
hostLine = candidate.Url.Length > 90 ? candidate.Url[..90] + "…" : candidate.Url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"【{string.Join("】【", tags)}】\n{hostLine}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把抽到的 JPEG 帧横向拼成一条预览图(约 3×160 宽),供列表行显示。
|
||||||
|
/// </summary>
|
||||||
|
public static Image? ComposeFrames(List<byte[]> frames)
|
||||||
|
{
|
||||||
|
if (frames == null || frames.Count == 0) return null;
|
||||||
|
|
||||||
|
List<Image> source = new List<Image>();
|
||||||
|
foreach (byte[] frame in frames)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Image img = Image.FromStream(new MemoryStream(frame));
|
||||||
|
source.Add(img);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 坏帧直接跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (source.Count == 0) return null;
|
||||||
|
|
||||||
|
const int cellW = 160;
|
||||||
|
const int gap = 8;
|
||||||
|
int cellH = PreviewHeight - 4;
|
||||||
|
|
||||||
|
var sizes = source.Select(img =>
|
||||||
|
{
|
||||||
|
int w = cellW;
|
||||||
|
int h = Math.Max(1, (int)Math.Round((double)cellW * img.Height / img.Width));
|
||||||
|
if (h > cellH)
|
||||||
|
{
|
||||||
|
h = cellH;
|
||||||
|
w = Math.Max(1, (int)Math.Round((double)cellH * img.Width / img.Height));
|
||||||
|
}
|
||||||
|
return (w, h);
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
int canvasW = source.Count * cellW + (source.Count - 1) * gap;
|
||||||
|
int canvasH = sizes.Max(s => s.h);
|
||||||
|
Bitmap strip = new Bitmap(canvasW, canvasH);
|
||||||
|
|
||||||
|
using (Graphics g = Graphics.FromImage(strip))
|
||||||
|
{
|
||||||
|
g.Clear(Color.Black);
|
||||||
|
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||||
|
for (int i = 0; i < source.Count; i++)
|
||||||
|
{
|
||||||
|
int x = i * (cellW + gap) + (cellW - sizes[i].w) / 2;
|
||||||
|
int y = (canvasH - sizes[i].h) / 2;
|
||||||
|
g.DrawImage(source[i], new Rectangle(x, y, sizes[i].w, sizes[i].h));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Image img in source) img.Dispose();
|
||||||
|
return strip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 一行候选:勾选框 + 截图预览条 + 说明文字。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class VideoRow : UserControl
|
||||||
|
{
|
||||||
|
private readonly CheckBox _check = new();
|
||||||
|
private readonly PictureBox _preview = new();
|
||||||
|
private readonly ToolTip _tip = new();
|
||||||
|
|
||||||
|
public VideoCandidate Candidate { get; }
|
||||||
|
public bool IsChecked => _check.Checked;
|
||||||
|
public event EventHandler? CheckChanged;
|
||||||
|
|
||||||
|
public VideoRow(VideoCandidate candidate, string description, bool isChecked)
|
||||||
|
{
|
||||||
|
Candidate = candidate;
|
||||||
|
Height = RowHeight;
|
||||||
|
Margin = new Padding(0);
|
||||||
|
|
||||||
|
TableLayoutPanel layout = new TableLayoutPanel
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
ColumnCount = 3,
|
||||||
|
RowCount = 1,
|
||||||
|
};
|
||||||
|
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 30F));
|
||||||
|
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, PreviewWidth + 8F));
|
||||||
|
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
|
||||||
|
|
||||||
|
_check.Dock = DockStyle.None;
|
||||||
|
_check.Anchor = AnchorStyles.Top;
|
||||||
|
_check.Location = new Point(4, 6);
|
||||||
|
_check.Checked = isChecked;
|
||||||
|
_check.CheckedChanged += (_, _) => CheckChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
_preview.Size = new Size(PreviewWidth, PreviewHeight);
|
||||||
|
_preview.SizeMode = PictureBoxSizeMode.Zoom;
|
||||||
|
_preview.BackColor = Color.FromArgb(28, 28, 28);
|
||||||
|
_preview.Image = Placeholder();
|
||||||
|
|
||||||
|
Label text = new Label
|
||||||
|
{
|
||||||
|
Dock = DockStyle.Fill,
|
||||||
|
Padding = new Padding(8, 4, 4, 2),
|
||||||
|
Text = description,
|
||||||
|
TextAlign = ContentAlignment.TopLeft,
|
||||||
|
};
|
||||||
|
|
||||||
|
layout.Controls.Add(_check, 0, 0);
|
||||||
|
layout.Controls.Add(_preview, 1, 0);
|
||||||
|
layout.Controls.Add(text, 2, 0);
|
||||||
|
Controls.Add(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetResult(Image? image, string? error)
|
||||||
|
{
|
||||||
|
_preview.Image?.Dispose();
|
||||||
|
if (image != null)
|
||||||
|
{
|
||||||
|
_preview.Image = image;
|
||||||
|
_tip.SetToolTip(_preview, string.Empty);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 把失败原因直接画在预览条上,一眼能看出是超时还是防盗链
|
||||||
|
string reason = string.IsNullOrWhiteSpace(error) ? "截图不可用" : error!;
|
||||||
|
_preview.Image = Placeholder(reason);
|
||||||
|
_tip.SetToolTip(_preview, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>截图还没生成好(或生成失败)时行内显示的占位条。</summary>
|
||||||
|
private static Image Placeholder(string? message = null)
|
||||||
|
{
|
||||||
|
Bitmap bmp = new Bitmap(PreviewWidth, PreviewHeight);
|
||||||
|
using Graphics g = Graphics.FromImage(bmp);
|
||||||
|
g.Clear(Color.FromArgb(28, 28, 28));
|
||||||
|
using (var cellBrush = new SolidBrush(Color.FromArgb(56, 56, 56)))
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
g.FillRectangle(cellBrush, i * 168, 0, 160, PreviewHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
using Font font = new Font("Microsoft YaHei UI", 8F);
|
||||||
|
string text = message ?? "截图生成中…";
|
||||||
|
Color color = message == null ? Color.Gray : Color.FromArgb(230, 140, 90);
|
||||||
|
// 失败原因可能较长,在预览条区域内自动换行
|
||||||
|
var rect = new RectangleF(4, 4, PreviewWidth - 8, PreviewHeight - 8);
|
||||||
|
using var format = new StringFormat { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.LineLimit };
|
||||||
|
g.DrawString(text, font, new SolidBrush(color), rect, format);
|
||||||
|
return bmp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
https://www.douyin.com/jingxuan?modal_id=7704345703665422713
|
||||||
Reference in new issue
Block a user