From 8260c417e21af16b318dd9155b55aa00378c20a5 Mon Sep 17 00:00:00 2001 From: owenchen Date: Fri, 6 Nov 2026 16:14:51 +0800 Subject: [PATCH] ow --- uniapp/Google Chrome/Form1.Designer.cs | 22 +- uniapp/Google Chrome/Form1.cs | 519 ++++++++++++++++-- uniapp/Google Chrome/PreviewLog.cs | 27 + uniapp/Google Chrome/VideoDownloader.cs | 181 +++++- .../Google Chrome/VideoListForm.Designer.cs | 101 ++++ uniapp/Google Chrome/VideoListForm.cs | 341 ++++++++++++ uniapp/Google Chrome/currentLink.txt | 1 + 7 files changed, 1148 insertions(+), 44 deletions(-) create mode 100644 uniapp/Google Chrome/PreviewLog.cs create mode 100644 uniapp/Google Chrome/VideoListForm.Designer.cs create mode 100644 uniapp/Google Chrome/VideoListForm.cs create mode 100644 uniapp/Google Chrome/currentLink.txt diff --git a/uniapp/Google Chrome/Form1.Designer.cs b/uniapp/Google Chrome/Form1.Designer.cs index effdf73..c6866bf 100644 --- a/uniapp/Google Chrome/Form1.Designer.cs +++ b/uniapp/Google Chrome/Form1.Designer.cs @@ -38,6 +38,7 @@ addLinkBut = new Button(); linkBut = new Button(); dlBut = new Button(); + listBut = new Button(); statusStrip1 = new StatusStrip(); statusLbl = new ToolStripStatusLabel(); progressBar = new ToolStripProgressBar(); @@ -80,7 +81,7 @@ // tableLayoutPanel2 // tableLayoutPanel2.AutoSize = true; - tableLayoutPanel2.ColumnCount = 7; + tableLayoutPanel2.ColumnCount = 8; tableLayoutPanel2.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); 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.Controls.Add(urlTxt, 0, 0); tableLayoutPanel2.Controls.Add(goBut, 1, 0); tableLayoutPanel2.Controls.Add(displayBut, 5, 0); tableLayoutPanel2.Controls.Add(linkBut, 4, 0); tableLayoutPanel2.Controls.Add(cssBut, 3, 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.Location = new Point(3, 3); tableLayoutPanel2.Name = "tableLayoutPanel2"; @@ -123,7 +126,7 @@ // // displayBut // - displayBut.Image =Google_Chrome.Properties.Resources.view_16; + displayBut.Image = Google_Chrome.Properties.Resources.view_16; displayBut.Location = new Point(1387, 3); displayBut.Name = "displayBut"; displayBut.Size = new Size(20, 23); @@ -173,6 +176,18 @@ dlBut.UseVisualStyleBackColor = true; 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.Items.AddRange(new ToolStripItem[] { statusLbl, progressBar, cancelBut }); @@ -236,6 +251,7 @@ private Button addLinkBut; private Button linkBut; private Button dlBut; + private Button listBut; private StatusStrip statusStrip1; private ToolStripStatusLabel statusLbl; private ToolStripProgressBar progressBar; diff --git a/uniapp/Google Chrome/Form1.cs b/uniapp/Google Chrome/Form1.cs index 86ea0e5..ea481c5 100644 --- a/uniapp/Google Chrome/Form1.cs +++ b/uniapp/Google Chrome/Form1.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using Microsoft.Web.WebView2.Core; namespace WinFormsApp3 @@ -29,6 +30,9 @@ namespace WinFormsApp3 private CancellationTokenSource? downloadCts; private bool hooksAttached; + /// 批量下载时用于识别“用户按了取消”,直接中止后续条目。 + private bool downloadWasCanceled; + public Form1() { InitializeComponent(); @@ -283,6 +287,34 @@ namespace WinFormsApp3 } } + /// + /// 把 WebView2 调用封送回 UI 线程执行:CoreWebView2 是 COM 接口, + /// 从线程池线程(如截图预览的后台任务)直接调会永久卡死,必须先封送。 + /// + private async Task OnUiAsync(Func> action) + { + var tcs = new TaskCompletionSource(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; + } + /// /// 嗅探到的地址可能来自不同 iframe,列表要加锁并限制长度, /// 只留最近若干个,供“当前播放地址取不到时”兜底用。 @@ -434,6 +466,300 @@ namespace WinFormsApp3 await DownloadCurrentVideoAsync(); } + private async void listBut_Click(object sender, EventArgs e) + { + await ShowVideoListAsync(); + } + + /// + /// 打开“本页视频列表”:列出全部候选并生成截图预览(每个视频 3 张、间隔 5 秒), + /// 用户对比确认哪个是当前视频后勾选,选文件夹批量下载。 + /// + 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 items, HashSet 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 cookies = new Dictionary(); + foreach (VideoCandidate item in items) + { + if (!cookies.ContainsKey(item.Url)) + { + cookies[item.Url] = await GetCookieHeaderAsync(item.Url); + } + } + List 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> 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; + } + } + + /// + /// 给列表行生成截图预览。当前正在播的视频直接用开窗前从播放器画面预取好的截图条 + /// (不受防盗链和地址过期影响);其余候选用 ffmpeg 带预取的 Referer/Cookie 读远端地址抽帧。 + /// 这个方法在后台线程上跑,绝不能调 WebView2 API(模态对话框期间回调不会送达)。 + /// + private async Task<(Image? Image, string? Error)> BuildPreviewAsync( + VideoCandidate candidate, bool isCurrent, Image? currentStrip, + IReadOnlyDictionary 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); + } + } + + /// + /// 从正在播放的 + private async Task> CaptureCurrentFramesAsync(CancellationToken ct) + { + List frames = new List(); + 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(raw); + if (string.IsNullOrEmpty(inner)) return frames; + + if (JsonNode.Parse(inner)?["frames"] is JsonArray arr) + { + foreach (JsonNode? item in arr) + { + string? dataUrl = item?.GetValue(); + 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;i0) return vids[i]; } + for(var j=0;j0) 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;kv.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}); +})()"; + + /// + /// 批量下载:逐个存进所选文件夹,音视频分离的轨对仍走精确合并; + /// 某条失败时询问是否继续,用户点取消则中止剩余条目。 + /// + private async Task DownloadCandidatesBatchAsync(List items, string folder) + { + int succeeded = 0; + List failures = new List(); + 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); + } + /// /// 只下载当前正在播放的那一个视频地址,不再列出一堆候选让用户挑。 /// @@ -512,40 +838,13 @@ namespace WinFormsApp3 /// private VideoCandidate? CollectCurrentCandidate(string grabResultJson) { - string current = string.Empty; - string pageUrl = string.Empty; - string dashVideo = string.Empty; - string dashAudio = string.Empty; - string videoId = string.Empty; - List sniffed = new List(); - - try - { - // ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号 - string? inner = JsonSerializer.Deserialize(grabResultJson); - if (!string.IsNullOrEmpty(inner)) - { - JsonNode? node = JsonNode.Parse(inner); - current = node?["current"]?.GetValue() ?? string.Empty; - pageUrl = node?["pageUrl"]?.GetValue() ?? string.Empty; - dashVideo = node?["dashVideo"]?.GetValue() ?? string.Empty; - dashAudio = node?["dashAudio"]?.GetValue() ?? string.Empty; - videoId = node?["videoId"]?.GetValue() ?? string.Empty; - - if (node?["sniffed"] is JsonArray arr) - { - foreach (JsonNode? item in arr) - { - string? u = item?.GetValue(); - if (!string.IsNullOrEmpty(u)) sniffed.Add(u); - } - } - } - } - catch - { - // 页面没有返回可解析结果时,只用宿主侧嗅探到的地址 - } + GrabInfo g = ParseGrab(grabResultJson); + string pageUrl = g.PageUrl; + List sniffed = g.Sniffed; + string dashVideo = g.DashVideo; + string dashAudio = g.DashAudio; + string videoId = g.VideoId; + string current = g.Current; // 第一层:video 标签自己的播放源 if (VideoDownloader.LooksLikeMedia(current) && !VideoDownloader.IsSegmentUrl(current)) @@ -594,6 +893,125 @@ namespace WinFormsApp3 return text == null ? null : new VideoCandidate(pageUrl, text, pageUrl); } + /// + /// 抓取脚本回包的解析结果,当前下载与列表两条路径共用。 + /// + 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 Sniffed = new(); + } + + private static GrabInfo ParseGrab(string grabResultJson) + { + GrabInfo g = new GrabInfo(); + try + { + // ExecuteScriptAsync 返回的是 JSON 编码后的字符串,需要先脱一层引号 + string? inner = JsonSerializer.Deserialize(grabResultJson); + if (!string.IsNullOrEmpty(inner)) + { + JsonNode? node = JsonNode.Parse(inner); + g.Current = node?["current"]?.GetValue() ?? string.Empty; + g.PageUrl = node?["pageUrl"]?.GetValue() ?? string.Empty; + g.DashVideo = node?["dashVideo"]?.GetValue() ?? string.Empty; + g.DashAudio = node?["dashAudio"]?.GetValue() ?? string.Empty; + g.VideoId = node?["videoId"]?.GetValue() ?? string.Empty; + + if (node?["sniffed"] is JsonArray arr) + { + foreach (JsonNode? item in arr) + { + string? u = item?.GetValue(); + if (!string.IsNullOrEmpty(u)) g.Sniffed.Add(u!); + } + } + } + } + catch + { + // 页面没有返回可解析结果时,只用宿主侧嗅探到的地址 + } + return g; + } + + /// + /// 给“列表”按钮收集本页全部候选:当前播放源、DASH 视频轨排最前并标记为当前, + /// 其余嗅探到的整片地址按新旧倒序追加;每条都尝试配对同组音频轨,下载时精确合并。 + /// + private (List Items, HashSet CurrentUrls) CollectCandidates(string grabResultJson) + { + GrabInfo g = ParseGrab(grabResultJson); + List items = new List(); + HashSet currentUrls = new HashSet(); + + 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 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); + } + + /// + /// 从地址里提视频 ID(抖音这类地址路径里带 10 位以上纯数字),用于音频轨配对。 + /// + 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; + } + } + /// /// 从已嗅探地址里找和视频轨同组的 DASH 音频轨。 /// 有 videoId 时优先选 URL 里包含该 ID 的音频轨,否则取最后一条 media-audio。 @@ -676,8 +1094,9 @@ namespace WinFormsApp3 /// /// 执行一次下载。返回 null 表示成功或用户主动取消,否则返回错误信息给上层提示。 + /// 批量下载时 promptOpenFolder 传 false,避免每条都弹“打开文件夹”。 /// - private async Task TryDownloadAsync(VideoCandidate candidate, string filePath) + private async Task TryDownloadAsync(VideoCandidate candidate, string filePath, bool promptOpenFolder = true) { VideoDownloader downloader = await CreateDownloaderAsync(candidate); @@ -706,7 +1125,7 @@ namespace WinFormsApp3 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) { OpenFolder(result.FilePath); @@ -716,7 +1135,8 @@ namespace WinFormsApp3 } catch (OperationCanceledException) { - // 用户主动取消就别再换地址重试了 + // 用户主动取消就别再换地址重试了;批量路径靠这个标志中止剩余条目 + downloadWasCanceled = true; SetStatus("已取消下载"); return null; } @@ -745,7 +1165,11 @@ namespace WinFormsApp3 return new VideoDownloader(referer, cookie); } - private async Task GetCookieHeaderAsync(string url) + /// + /// 取 Cookie 也是 CoreWebView2 COM 调用,同样封送回 UI 线程, + /// 否则截图预览的后台任务会在这里永久卡死。 + /// + private Task GetCookieHeaderAsync(string url) => OnUiAsync(async () => { CoreWebView2? core = webView21.CoreWebView2; if (core == null || !Uri.TryCreate(url, UriKind.Absolute, out Uri? uri)) return null; @@ -763,7 +1187,7 @@ namespace WinFormsApp3 // 拿不到 Cookie 就按匿名请求下载 return null; } - } + }); private void ReportProgress(DownloadProgress value) { @@ -801,6 +1225,23 @@ namespace WinFormsApp3 } } + /// + /// 批量下载时同页多个候选可能推断出同名文件,重名时加序号避免互相覆盖。 + /// + 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) { try diff --git a/uniapp/Google Chrome/PreviewLog.cs b/uniapp/Google Chrome/PreviewLog.cs new file mode 100644 index 0000000..ce71df7 --- /dev/null +++ b/uniapp/Google Chrome/PreviewLog.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; + +namespace WinFormsApp3 +{ + /// + /// 截图预览诊断日志:exe 目录下 preview_debug.log。 + /// 挂起类问题光看界面定位不到,必须把每一步(含线程号)落到磁盘上。 + /// + 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 + { + // 日志写失败不影响功能 + } + } + } +} diff --git a/uniapp/Google Chrome/VideoDownloader.cs b/uniapp/Google Chrome/VideoDownloader.cs index b637e9b..592e6f1 100644 --- a/uniapp/Google Chrome/VideoDownloader.cs +++ b/uniapp/Google Chrome/VideoDownloader.cs @@ -265,11 +265,24 @@ namespace WinFormsApp3 } /// - /// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4(直接 copy 不重编码)。 + /// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4。 + /// 优先两条流直接 copy:不重编码、时间戳原样保留,音画对齐最准确; + /// 只有音频轨格式装不进 mp4 容器时才回退到 AAC 转码。 /// private static async Task 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 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; } + /// + /// 抽帧结果:成功时 Frames 非空;失败时 Error 给界面显示原因,不让用户干等。 + /// + public record FrameExtraction(List Frames, string? Error); + + /// + /// 抽取预览截图:每 intervalSeconds 秒取一张、共取 frameCount 张(默认 5 秒 3 张), + /// 供下载列表肉眼对比哪个是当前视频。用 ffmpeg 直接读远端地址,带 Referer/Cookie 过防盗链。 + /// 单次尝试限 25 秒,超时不再重试第二次,避免地址失效时列表长时间卡在“生成中”。 + /// + public async Task ExtractFramesAsync(string url, CancellationToken ct, int intervalSeconds = 5, int frameCount = 3) + { + var ffmpeg = FindFfmpeg(); + if (ffmpeg == null) + { + return new FrameExtraction(new List(), "未找到 ffmpeg,无法生成截图"); + } + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return new FrameExtraction(new List(), "该地址无法直接抽帧"); + } + + 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 + { + "-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(), lastError ?? "没抽到帧"); + } + finally + { + try { Directory.Delete(tmpDir, true); } catch { /* 临时目录删不掉不影响结果 */ } + } + } + + /// + /// ffmpeg 走网络也要过防盗链:把下载器身上的 UA / Referer / Cookie 拼成 -headers 参数。 + /// + 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(); + } + + /// ffmpeg 单次运行结果:TimedOut 表示被外部杀掉,Error 给界面显示原因。 + private record FfmpegRun(bool TimedOut, string? Error); + + private static async Task RunFfmpegArgsAsync(string ffmpeg, List 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); + } + + /// 取 ffmpeg 报错里最有信息量的一行,太长截断,直接丢给界面显示。 + 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 }) diff --git a/uniapp/Google Chrome/VideoListForm.Designer.cs b/uniapp/Google Chrome/VideoListForm.Designer.cs new file mode 100644 index 0000000..8b2faf2 --- /dev/null +++ b/uniapp/Google Chrome/VideoListForm.Designer.cs @@ -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; + } +} diff --git a/uniapp/Google Chrome/VideoListForm.cs b/uniapp/Google Chrome/VideoListForm.cs new file mode 100644 index 0000000..8be24e4 --- /dev/null +++ b/uniapp/Google Chrome/VideoListForm.cs @@ -0,0 +1,341 @@ +using System.Drawing.Drawing2D; + +namespace WinFormsApp3 +{ + /// + /// 本页视频下载列表:每个候选视频用 ffmpeg 抽 3 张间隔 5 秒的截图拼成预览条, + /// 肉眼对比哪个是当前视频后勾选,选文件夹批量下载。 + /// 当前正在播放的那条默认勾上。 + /// + public partial class VideoListForm : Form + { + private const int RowHeight = 124; + private const int PreviewWidth = 496; + private const int PreviewHeight = 100; + + private readonly Func> _previewFactory; + private readonly List _rows = new(); + private readonly CancellationTokenSource _loadCts = new(); + private int _generatedCount; + private int _failedCount; + + /// 点“下载选中”后带回的勾选候选(按列表顺序)。 + public List SelectedCandidates { get; } = new(); + + public VideoListForm(IReadOnlyList candidates, IReadOnlyCollection currentUrls, + Func> previewFactory) + { + InitializeComponent(); + + _previewFactory = previewFactory; + BuildRows(candidates, currentUrls); + } + + private void BuildRows(IReadOnlyList candidates, IReadOnlyCollection 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(); + } + + /// + /// 截图预览在后台生成(三条并发),生成一条刷新一条; + /// 失败时把原因直接写在预览条上,不让界面一直停在“生成中…”。 + /// + 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 tags = new List(); + 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}"; + } + + /// + /// 把抽到的 JPEG 帧横向拼成一条预览图(约 3×160 宽),供列表行显示。 + /// + public static Image? ComposeFrames(List frames) + { + if (frames == null || frames.Count == 0) return null; + + List source = new List(); + 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; + } + + /// + /// 一行候选:勾选框 + 截图预览条 + 说明文字。 + /// + 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); + } + } + + /// 截图还没生成好(或生成失败)时行内显示的占位条。 + 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; + } + } + } +} diff --git a/uniapp/Google Chrome/currentLink.txt b/uniapp/Google Chrome/currentLink.txt new file mode 100644 index 0000000..e73e90d --- /dev/null +++ b/uniapp/Google Chrome/currentLink.txt @@ -0,0 +1 @@ +https://www.douyin.com/jingxuan?modal_id=7704345703665422713 \ No newline at end of file