# Conflicts:
#	uniapp/security/src/manifest.json
This commit is contained in:
itcsx committed 2026-11-08 17:11:15 +08:00
commit 25e10f7c10
51 files changed
+4306 -94

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace WinFormsApp3
{
internal class Class1
{
}
}/*
// 这是正确、最简、不会报错的 NER 辅助类
public class NerInput
{
public string Text { get; set; }
}
public class NerOutput
{
// 不需要 Label!不会报错!
public string[] Words { get; set; }
}
var mlContext = new MLContext();
// ============================
// 关键:只做 分词 + 特征转换
// 绝对不加分类器,就不会要 Label
// ============================
var pipeline = mlContext.Transforms.Text.TokenizeIntoWords("Words", nameof(NerInput.Text));
// 空数据也不会报错
var emptyData = mlContext.Data.LoadFromEnumerable(new List<NerInput>());
var model = pipeline.Fit(emptyData);
// 预测引擎
var engine = mlContext.Model.CreatePredictionEngine<NerInput, NerOutput>(model);
// 测试
var result = engine.Predict(new NerInput
{
Text = "我和陈明今天去上海见张伟"
});
Console.WriteLine("分词结果:");
foreach (var word in result.Words)
{
Console.WriteLine("- " + word);
}
*/
+260
View File
@@ -0,0 +1,260 @@
namespace WinFormsApp3
{
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
tableLayoutPanel1 = new TableLayoutPanel();
webView21 = new Microsoft.Web.WebView2.WinForms.WebView2();
tableLayoutPanel2 = new TableLayoutPanel();
urlTxt = new TextBox();
goBut = new Button();
displayBut = new Button();
cssBut = new Button();
addLinkBut = new Button();
linkBut = new Button();
dlBut = new Button();
listBut = new Button();
statusStrip1 = new StatusStrip();
statusLbl = new ToolStripStatusLabel();
progressBar = new ToolStripProgressBar();
cancelBut = new ToolStripButton();
tableLayoutPanel1.SuspendLayout();
((System.ComponentModel.ISupportInitialize)webView21).BeginInit();
tableLayoutPanel2.SuspendLayout();
statusStrip1.SuspendLayout();
SuspendLayout();
//
// tableLayoutPanel1
//
tableLayoutPanel1.ColumnCount = 1;
tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 20F));
tableLayoutPanel1.Controls.Add(webView21, 0, 1);
tableLayoutPanel1.Controls.Add(tableLayoutPanel2, 0, 0);
tableLayoutPanel1.Controls.Add(statusStrip1, 0, 2);
tableLayoutPanel1.Dock = DockStyle.Fill;
tableLayoutPanel1.Location = new Point(0, 0);
tableLayoutPanel1.Name = "tableLayoutPanel1";
tableLayoutPanel1.RowCount = 3;
tableLayoutPanel1.RowStyles.Add(new RowStyle());
tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 50F));
tableLayoutPanel1.RowStyles.Add(new RowStyle());
tableLayoutPanel1.Size = new Size(1416, 785);
tableLayoutPanel1.TabIndex = 1;
//
// webView21
//
webView21.AllowExternalDrop = true;
webView21.DefaultBackgroundColor = Color.White;
webView21.Dock = DockStyle.Fill;
webView21.Location = new Point(3, 38);
webView21.Name = "webView21";
webView21.Size = new Size(1410, 715);
webView21.TabIndex = 0;
webView21.ZoomFactor = 1D;
//
// tableLayoutPanel2
//
tableLayoutPanel2.AutoSize = true;
tableLayoutPanel2.ColumnCount = 8;
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());
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(listBut, 6, 0);
tableLayoutPanel2.Controls.Add(dlBut, 7, 0);
tableLayoutPanel2.Dock = DockStyle.Fill;
tableLayoutPanel2.Location = new Point(3, 3);
tableLayoutPanel2.Name = "tableLayoutPanel2";
tableLayoutPanel2.RowCount = 1;
tableLayoutPanel2.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
tableLayoutPanel2.Size = new Size(1410, 29);
tableLayoutPanel2.TabIndex = 1;
//
// urlTxt
//
urlTxt.Dock = DockStyle.Fill;
urlTxt.Location = new Point(3, 3);
urlTxt.Name = "urlTxt";
urlTxt.Size = new Size(1134, 23);
urlTxt.TabIndex = 0;
//
// goBut
//
goBut.Image = Google_Chrome.Properties.Resources.ok_16;
goBut.Location = new Point(1143, 3);
goBut.Name = "goBut";
goBut.Size = new Size(55, 23);
goBut.TabIndex = 1;
goBut.UseVisualStyleBackColor = true;
goBut.Click += goBut_Click;
//
// displayBut
//
displayBut.Image = Google_Chrome.Properties.Resources.view_16;
displayBut.Location = new Point(1387, 3);
displayBut.Name = "displayBut";
displayBut.Size = new Size(20, 23);
displayBut.TabIndex = 1;
displayBut.UseVisualStyleBackColor = true;
displayBut.Click += displayBut_Click;
//
// cssBut
//
cssBut.Image = Google_Chrome.Properties.Resources.element;
cssBut.Location = new Point(1265, 3);
cssBut.Name = "cssBut";
cssBut.Size = new Size(55, 23);
cssBut.TabIndex = 1;
cssBut.UseVisualStyleBackColor = true;
cssBut.Click += cssBut_Click;
//
// addLinkBut
//
addLinkBut.Image = Google_Chrome.Properties.Resources.element_plus_16;
addLinkBut.Location = new Point(1204, 3);
addLinkBut.Name = "addLinkBut";
addLinkBut.Size = new Size(55, 23);
addLinkBut.TabIndex = 1;
addLinkBut.UseVisualStyleBackColor = true;
addLinkBut.Click += addLinkBut_Click;
//
// linkBut
//
linkBut.Image = Google_Chrome.Properties.Resources.page_link;
linkBut.Location = new Point(1326, 3);
linkBut.Name = "linkBut";
linkBut.Size = new Size(55, 23);
linkBut.TabIndex = 1;
linkBut.UseVisualStyleBackColor = true;
linkBut.Click += linkBut_Click;
//
// dlBut
//
dlBut.Image = Google_Chrome.Properties.Resources.save_16;
dlBut.Location = new Point(1382, 3);
dlBut.Name = "dlBut";
dlBut.Size = new Size(75, 23);
dlBut.TabIndex = 1;
dlBut.Text = "下载";
dlBut.TextImageRelation = TextImageRelation.ImageBeforeText;
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 });
statusStrip1.Location = new Point(0, 759);
statusStrip1.Name = "statusStrip1";
statusStrip1.Size = new Size(1410, 26);
statusStrip1.TabIndex = 2;
//
// statusLbl
//
statusLbl.Name = "statusLbl";
statusLbl.Size = new Size(1080, 21);
statusLbl.Spring = true;
statusLbl.Text = "就绪";
statusLbl.TextAlign = ContentAlignment.MiddleLeft;
//
// progressBar
//
progressBar.Name = "progressBar";
progressBar.Size = new Size(200, 18);
progressBar.Visible = false;
//
// cancelBut
//
cancelBut.DisplayStyle = ToolStripItemDisplayStyle.Text;
cancelBut.Enabled = false;
cancelBut.Name = "cancelBut";
cancelBut.Size = new Size(60, 23);
cancelBut.Text = "取消下载";
cancelBut.Click += cancelBut_Click;
//
// Form1
//
AutoScaleDimensions = new SizeF(7F, 17F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(1416, 785);
Controls.Add(tableLayoutPanel1);
Name = "Form1";
Text = "在线";
FormClosing += Form1_FormClosing;
Load += Form1_Load;
tableLayoutPanel1.ResumeLayout(false);
tableLayoutPanel1.PerformLayout();
((System.ComponentModel.ISupportInitialize)webView21).EndInit();
tableLayoutPanel2.ResumeLayout(false);
tableLayoutPanel2.PerformLayout();
statusStrip1.ResumeLayout(false);
statusStrip1.PerformLayout();
ResumeLayout(false);
}
#endregion
private TableLayoutPanel tableLayoutPanel1;
private Microsoft.Web.WebView2.WinForms.WebView2 webView21;
private TableLayoutPanel tableLayoutPanel2;
private TextBox urlTxt;
private Button goBut;
private Button displayBut;
private Button cssBut;
private Button addLinkBut;
private Button linkBut;
private Button dlBut;
private Button listBut;
private StatusStrip statusStrip1;
private ToolStripStatusLabel statusLbl;
private ToolStripProgressBar progressBar;
private ToolStripButton cancelBut;
}
}
File diff suppressed because it is too large. Load diff
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+71
View File
@@ -0,0 +1,71 @@
namespace WinFormsApp3
{
partial class Form2
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
cssTxt = new RichTextBox();
saveBut = new Button();
SuspendLayout();
//
// cssTxt
//
cssTxt.Location = new Point(12, 12);
cssTxt.Name = "cssTxt";
cssTxt.Size = new Size(772, 366);
cssTxt.TabIndex = 0;
cssTxt.Text = "";
//
// saveBut
//
saveBut.Image =Google_Chrome.Properties.Resources.save_16;
saveBut.Location = new Point(699, 395);
saveBut.Name = "saveBut";
saveBut.Size = new Size(57, 43);
saveBut.TabIndex = 1;
saveBut.UseVisualStyleBackColor = true;
saveBut.Click += saveBut_Click;
//
// Form2
//
AutoScaleDimensions = new SizeF(7F, 17F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new Size(800, 450);
Controls.Add(saveBut);
Controls.Add(cssTxt);
Name = "Form2";
Text = "Form2";
Load += Form2_Load;
ResumeLayout(false);
}
#endregion
private RichTextBox cssTxt;
private Button saveBut;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WinFormsApp3
{
public partial class Form2 : Form
{
public string Path { get; set; }
public Form2()
{
InitializeComponent();
}
private void Form2_Load(object sender, EventArgs e)
{
if (File.Exists(Path))
{
cssTxt.Text = File.ReadAllText(Path);
}
}
private void saveBut_Click(object sender, EventArgs e)
{
File.WriteAllText(Path, cssTxt.Text);
this.Close();
}
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+56
View File
@@ -0,0 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- _hostest 是独立测试项目,不能被主项目编译 -->
<Compile Remove="_hostest/**/*.cs" />
<None Remove="_hostest/**/*" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.ML" Version="5.0.0" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.26.0" />
<PackageReference Include="Microsoft.ML.OnnxTransformer" Version="5.0.0" />
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
</ItemGroup>
<ItemGroup>
<!-- 页面注入脚本:必须拷到输出目录,下载按钮靠它们嗅探视频真实地址 -->
<None Update="Resources\media_sniffer.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources\video_button.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources\video_grab.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="ffmpeg.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Compile Update="Form1.cs">
<SubType>Form</SubType>
</Compile>
<Compile Update="Form2.cs">
<SubType>Form</SubType>
</Compile>
</ItemGroup>
</Project>
+25
View File
@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.11.12328.437 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Google Chrome", "Google Chrome.csproj", "{CF401873-0387-4EB8-85E3-9B757EF9EA54}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{CF401873-0387-4EB8-85E3-9B757EF9EA54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CF401873-0387-4EB8-85E3-9B757EF9EA54}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CF401873-0387-4EB8-85E3-9B757EF9EA54}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CF401873-0387-4EB8-85E3-9B757EF9EA54}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0AF3BB1F-186C-4D9D-8E04-1935ECCD5E4A}
EndGlobalSection
EndGlobal
+27
View File
@@ -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
{
// 日志写失败不影响功能
}
}
}
}
+109
View File
@@ -0,0 +1,109 @@
using Microsoft.ML;
using Microsoft.ML.Data;
using Microsoft.ML.Transforms.Onnx;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using WinFormsApp3;
//https://huggingface.co/protectai/gyr66-bert-base-chinese-finetuned-ner-onnx/tree/main
//model.onnx
//vocab.txt
namespace WinFormsApp3
{
class Program
{
[STAThread] // ← 加上这个,99% 立刻解决你的错误
static int Main(string[] args)
{
// 全局未处理异常兜底:写日志而不是直接闪退,放到最前面
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
try { File.WriteAllText("crash.log", $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {(e.IsTerminating ? "FATAL" : "NON-FATAL")}\n{e.ExceptionObject}"); }
catch { }
};
// 命令行下载:WinFormsApp3.exe --download <视频地址> [--out 保存路径] [--referer 页面地址]
if (args.Length > 0 && string.Equals(args[0], "--download", StringComparison.OrdinalIgnoreCase))
{
// 下载丢给线程池线程去跑,不要在 STA 主线程上等待异步,否则 COM 线程模式会冲突
return Task.Run(() => DownloadCliAsync(args)).GetAwaiter().GetResult();
}
ApplicationConfiguration.Initialize();
try
{
Application.Run(new Form1());
}
catch (Exception ex)
{
File.WriteAllText("crash.log", $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Application.Run\n{ex}");
throw;
}
return 0;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AttachConsole(int dwProcessId);
private const int AttachParentProcess = -1;
/// <summary>
/// 命令行下载入口:不创建窗体、不初始化 WebView2,直接把地址下完就退出,方便脚本化验证。
/// </summary>
private static async Task<int> DownloadCliAsync(string[] args)
{
// WinExe 默认没有控制台,Attach 到父进程的输出才能在终端里看到结果
AttachConsole(AttachParentProcess);
string url = args.Length > 1 ? args[1] : string.Empty;
if (string.IsNullOrWhiteSpace(url) || url.StartsWith("--", StringComparison.Ordinal))
{
Console.Error.WriteLine("用法: WinFormsApp3.exe --download <视频地址> [--out 保存路径] [--referer 页面地址]");
return 2;
}
string? referer = GetOption(args, "--referer");
string? outPath = GetOption(args, "--out");
if (string.IsNullOrWhiteSpace(outPath))
{
outPath = Path.Combine(Path.GetTempPath(), new VideoDownloader(referer).SuggestedFileName(url, null));
}
using CancellationTokenSource cts = new(TimeSpan.FromMinutes(15));
Progress<DownloadProgress> progress = new(message => Console.WriteLine(message.Message));
try
{
VideoDownloader downloader = new(referer);
DownloadResult result = await downloader.DownloadAsync(url, outPath!, progress, cts.Token);
Console.WriteLine($"OK file={result.FilePath} bytes={result.Bytes} converted={result.Converted}");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"FAIL {ex.GetType().Name}: {ex.Message}");
return 1;
}
}
/// <summary>
/// 取 --out / --referer 这类"选项 值"形式的参数。
/// </summary>
private static string? GetOption(string[] args, string name)
{
for (int i = 0; i < args.Length - 1; i++)
{
if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase))
{
return args[i + 1];
}
}
return null;
}
}
}
+133
View File
@@ -0,0 +1,133 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 這段程式碼是由工具產生的。
// 執行階段版本:4.0.30319.42000
//
// 對這個檔案所做的變更可能會造成錯誤的行為,而且如果重新產生程式碼,
// 變更將會遺失。
// </auto-generated>
//------------------------------------------------------------------------------
namespace Google_Chrome.Properties {
using System;
/// <summary>
/// 用於查詢當地語系化字串等的強類型資源類別。
/// </summary>
// 這個類別是自動產生的,是利用 StronglyTypedResourceBuilder
// 類別透過 ResGen 或 Visual Studio 這類工具。
// 若要加入或移除成員,請編輯您的 .ResX 檔,然後重新執行 ResGen
// (利用 /str 選項),或重建您的 VS 專案。
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// 傳回這個類別使用的快取的 ResourceManager 執行個體。
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Google_Chrome.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// 覆寫目前執行緒的 CurrentUICulture 屬性,對象是所有
/// 使用這個強類型資源類別的資源查閱。
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap element {
get {
object obj = ResourceManager.GetObject("element", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap element_plus_16 {
get {
object obj = ResourceManager.GetObject("element_plus_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap ok_16 {
get {
object obj = ResourceManager.GetObject("ok_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap page_link {
get {
object obj = ResourceManager.GetObject("page_link", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap save_16 {
get {
object obj = ResourceManager.GetObject("save_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap search_16 {
get {
object obj = ResourceManager.GetObject("search_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// 查詢類型 System.Drawing.Bitmap 的當地語系化資源。
/// </summary>
internal static System.Drawing.Bitmap view_16 {
get {
object obj = ResourceManager.GetObject("view_16", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
}
}
@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="view_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\view_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="search_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\search_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="ok_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\ok_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="element_plus_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\element_plus_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="page_link" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\page_link.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="element" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\element.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="save_16" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\save_16.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
</root>
Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

@@ -0,0 +1,144 @@
// 资源嗅探脚本:在安装页面播放器脚本之前接管 fetch / XMLHttpRequest / createObjectURL,
// 把真正被下载的媒体地址(mp4 / m3u8 等)回报给宿主程序,用于"下载视频"按钮。
(function () {
if (window.__mediaSniffInstalled) return;
window.__mediaSniffInstalled = true;
window.__mediaUrls = [];
window.__m3u8Blobs = [];
var MEDIA_EXT =
/\.(mp4|flv|m3u8|mpd|mkv|mov|m4v|webm|f4v|3gp|mpg|mpeg|ts)(\?|#|$)/i;
var PLAIN_EXT =
/\.(jpg|jpeg|png|gif|webp|ico|svg|bmp|css|js|json|woff2?|ttf|map|txt)(\?|#|$)/i;
function isMediaUrl(u) {
if (!u) return false;
u = String(u);
if (u.indexOf("blob:") === 0 || u.indexOf("data:") === 0) return false;
if (PLAIN_EXT.test(u)) return false;
return MEDIA_EXT.test(u) || /\/(video|vod|playback|media)[\/-]/i.test(u);
}
function absUrl(u) {
try {
return new URL(String(u), location.href).href;
} catch (e) {
return String(u);
}
}
function post(msg) {
try {
if (
window.chrome &&
window.chrome.webview &&
window.chrome.webview.postMessage
) {
window.chrome.webview.postMessage(JSON.stringify(msg));
}
} catch (e) {}
}
function remember(text) {
if (!text || typeof text !== "string") return;
if (text.indexOf("#EXTM3U") < 0) return;
if (window.__m3u8Blobs.indexOf(text) >= 0) return;
window.__m3u8Blobs.push(text);
post({ type: "hlsText", text: text });
}
function report(u) {
try {
var full = absUrl(u);
if (!isMediaUrl(full)) return;
if (window.__mediaUrls.indexOf(full) >= 0) return;
window.__mediaUrls.push(full);
// base 是发出这个请求的文档地址:播放器常在跨域 iframe 里,
// 宿主必须用它当 Referer,用顶层页面地址会被防盗链判为非法来源
post({ type: "media", url: full, base: location.href });
} catch (e) {}
}
window.__reportMedia = report;
window.__rememberHlsText = remember;
// 抖音等站点会用 atob(base64) 把播放地址还原出来,这里顺手抓一份解码结果
try {
var origAtob = window.atob;
if (origAtob) {
window.atob = function (s) {
var r = origAtob.apply(this, arguments);
try {
if (r && typeof r === "string") {
if (r.indexOf("#EXTM3U") >= 0) remember(r);
else if (isMediaUrl(r)) report(r);
}
} catch (e) {}
return r;
};
}
} catch (e) {}
try {
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function (input, init) {
try {
var u =
typeof input === "string" || input instanceof URL
? input
: input && input.url;
if (u) report(u);
} catch (e) {}
return origFetch.apply(this, arguments);
};
}
} catch (e) {}
try {
var xhrOpen = XMLHttpRequest.prototype.open;
var xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, u) {
try {
if (u) report(u);
} catch (e) {}
return xhrOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
var xhr = this;
try {
xhr.addEventListener("load", function () {
try {
if (xhr.responseType === "" || xhr.responseType === "text") {
remember(xhr.responseText);
} else if (
xhr.responseType === "blob" &&
xhr.response &&
xhr.response.text
) {
xhr.response.text().then(remember);
}
} catch (e) {}
});
} catch (e) {}
return xhrSend.apply(this, arguments);
};
} catch (e) {}
// MSE 播放时 video.src 只能是 blob:,抓不到 http 地址,所以靠上面的分片嗅探兜底;
// 这里额外记录 createObjectURL 的调用,方便识别"确实在用 MSE"。
try {
var ocu = window.URL && window.URL.createObjectURL;
if (ocu) {
window.URL.createObjectURL = function (obj) {
var u = ocu.apply(this, arguments);
try {
window.__usesMse = true;
if (obj && typeof obj.text === "function") obj.text().then(remember);
} catch (e) {}
return u;
};
}
} catch (e) {}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

@@ -0,0 +1,80 @@
// 在页面里注入一个悬浮"下载视频"按钮,贴在当前视频播放器右下角。
// 点击后通过 postMessage 通知宿主程序开始下载流程。
(function () {
var BTN_ID = "qz-video-download-btn";
function post(msg) {
try {
if (
window.chrome &&
window.chrome.webview &&
window.chrome.webview.postMessage
) {
window.chrome.webview.postMessage(JSON.stringify(msg));
}
} catch (e) {}
}
function bigVideo() {
var vids = document.querySelectorAll("video");
var best = null,
bestArea = 0;
for (var i = 0; i < vids.length; i++) {
var r = vids[i].getBoundingClientRect();
var area = r.width * r.height;
if (r.width > 120 && r.height > 60 && area > bestArea) {
best = vids[i];
bestArea = area;
}
}
return best;
}
function ensureButton() {
var btn = document.getElementById(BTN_ID);
var v = bigVideo();
if (!btn && v) {
btn = document.createElement("button");
btn.id = BTN_ID;
btn.type = "button";
btn.innerText = "下载视频";
btn.style.cssText =
"position:fixed;z-index:2147483647;padding:5px 12px;border:0;border-radius:16px;" +
"background:#ff2d55;color:#fff;font-size:13px;line-height:1.4;cursor:pointer;" +
'box-shadow:0 2px 8px rgba(0,0,0,.35);font-family:system-ui,"Microsoft YaHei",sans-serif;';
btn.addEventListener(
"click",
function (e) {
e.preventDefault();
e.stopPropagation();
post({ type: "downloadClick", pageUrl: location.href });
},
true,
);
document.documentElement.appendChild(btn);
}
if (btn) {
if (!v) {
btn.style.display = "none";
return;
}
var r = v.getBoundingClientRect();
btn.style.display = "block";
btn.style.left = Math.max(8, r.right - btn.offsetWidth - 12) + "px";
btn.style.top = Math.max(8, r.bottom - btn.offsetHeight - 12) + "px";
}
}
// 播放进度变化时(抖音翻页、切换清晰度)重新定位
document.addEventListener("timeupdate", ensureButton, true);
window.addEventListener("resize", ensureButton, true);
window.addEventListener("scroll", ensureButton, true);
document.addEventListener("mouseover", ensureButton, true);
if (!window.__downloadBtnTimer) {
window.__downloadBtnTimer = setInterval(ensureButton, 800);
}
ensureButton();
})();
@@ -0,0 +1,132 @@
// 点击"下载视频"时执行:优先从页面内嵌数据 RENDER_DATA 精确提取当前视频地址,
// 其次才用嗅探列表兜底。
(function () {
try {
var pageUrl = location.href;
// 从 URL 提取视频 ID
var videoId = "";
var m = pageUrl.match(/[?&]modal_id=(\d+)/);
if (m) videoId = m[1];
if (!videoId) {
var m0 = pageUrl.match(/[?&]aweme_id=(\d+)/);
if (m0) videoId = m0[1];
}
if (!videoId) {
var m2 = pageUrl.match(/\/(?:video|note)\/(\d+)/);
if (m2) videoId = m2[1];
}
// === 方案一:从 RENDER_DATA 里精确找当前视频的播放地址 ===
var renderVideoUrl = "";
var renderAudioUrl = "";
try {
var rd = document.getElementById("RENDER_DATA");
if (rd && rd.textContent) {
var decoded = decodeURIComponent(rd.textContent);
// 在 JSON 里找 aweme_id 等于 videoId 的节点,提取其 video.play_addr 或 play_addr 列表
// 用正则粗匹配,避免完整 JSON 解析的复杂性
var idx = decoded.indexOf(videoId);
while (idx >= 0) {
// 在这个 ID 附近找 playApi / play_addr / url_list
var chunk = decoded.substring(Math.max(0, idx - 200), idx + 5000);
// 找 DASH 格式的视频轨和音频轨
var vMatches = chunk.match(/https?:\/\/[^"\\]+media-video[^"\\]*/gi);
var aMatches = chunk.match(/https?:\/\/[^"\\]+media-audio[^"\\]*/gi);
if (vMatches && vMatches.length) {
renderVideoUrl = vMatches[vMatches.length - 1];
if (aMatches && aMatches.length) {
renderAudioUrl = aMatches[aMatches.length - 1];
}
break;
}
// 找普通 mp4 直链
var mp4Matches = chunk.match(/https?:\/\/[^"\\]*\.mp4[^"\\]*/gi);
if (mp4Matches && mp4Matches.length) {
renderVideoUrl = mp4Matches[mp4Matches.length - 1];
break;
}
idx = decoded.indexOf(videoId, idx + 1);
}
}
} catch (e) { /* RENDER_DATA 解析失败就走嗅探兜底 */ }
// === 方案二:选正在播放的 <video> 元素 ===
var vids = Array.prototype.slice.call(document.querySelectorAll("video"));
var best = null;
for (var i = 0; i < vids.length; i++) {
var v = vids[i];
if (!v.paused && v.currentTime > 0) { best = v; break; }
}
if (!best) {
for (var j = 0; j < vids.length; j++) {
var v2 = vids[j];
if (v2.videoWidth > 0 || (!isNaN(v2.duration) && v2.duration > 0 && v2.currentTime > 0)) {
best = v2;
break;
}
}
}
if (!best) best = vids[0];
var current = "";
var playable = true;
if (best) {
current = best.currentSrc || best.src || "";
if (!current) {
var s = best.querySelector && best.querySelector("source");
if (s) current = s.src || s.getAttribute("src") || "";
}
if (best.networkState === 0) playable = false;
}
// === 方案三:嗅探列表兜底 ===
var all = window.__mediaUrls || [];
var videoTracks = [];
var audioTracks = [];
var otherMedia = [];
for (var k = 0; k < all.length; k++) {
var u = all[k];
if (/media-video/i.test(u)) videoTracks.push(u);
else if (/media-audio/i.test(u)) audioTracks.push(u);
else otherMedia.push(u);
}
function pickLast(arr) {
return arr.length ? arr[arr.length - 1] : "";
}
var dashVideo = pickLast(videoTracks);
var dashAudio = pickLast(audioTracks);
// 如果 RENDER_DATA 找到了,优先用它
if (renderVideoUrl) {
dashVideo = renderVideoUrl;
if (renderAudioUrl) dashAudio = renderAudioUrl;
}
var title = document.title || "";
var h1 = document.querySelector("h1");
if (h1 && h1.innerText) title = h1.innerText;
return JSON.stringify({
current: current || "",
playable: playable,
usesMse: !!window.__usesMse || /^blob:/i.test(current),
title: title,
pageUrl: pageUrl,
videoId: videoId,
sniffed: otherMedia.slice(-60),
dashVideo: dashVideo,
dashAudio: dashAudio,
m3u8Blobs: (window.__m3u8Blobs || []).slice(-1),
});
} catch (e) {
return JSON.stringify({
current: "",
title: document.title || "",
pageUrl: location.href,
error: String(e),
});
}
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

+886
View File
@@ -0,0 +1,886 @@
using System.Diagnostics;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace WinFormsApp3
{
/// <summary>
/// 下载进度:文字说明 + 可选的百分比(0~1)。
/// </summary>
public record DownloadProgress(string Message, double? Ratio);
/// <summary>
/// 下载结果。
/// </summary>
/// <param name="FilePath">最终落盘的文件路径(HLS 转封装后可能改变扩展名)。</param>
/// <param name="Bytes">写出的字节数。</param>
/// <param name="Converted">是否由 ffmpeg 把 ts 转封装为 mp4。</param>
public record DownloadResult(string FilePath, long Bytes, bool Converted);
/// <summary>
/// 视频下载器:支持直链(mp4/flv/ts…)与 HLS(m3u8)分片合并下载。
/// 请求时带上当前页面 Referer,抖音等站点校验 Referer 后才允许访问播放地址。
/// </summary>
public class VideoDownloader
{
private const string UserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0";
private static readonly Regex CharsetRx = new(@"charset\s*=\s*""?([\w-]+)""?", RegexOptions.IgnoreCase);
private static readonly string[] MediaExtensions =
[".mp4", ".flv", ".m3u8", ".mpd", ".mkv", ".mov", ".m4v", ".webm", ".f4v", ".3gp", ".ts", ".mpg", ".mpeg"];
/// <summary>
/// 整片容器格式。这些后缀永远不能当作 HLS 分片,否则 /video/xxx.flv 这类地址会被误删。
/// </summary>
private static readonly string[] WholeVideoExtensions =
[".mp4", ".flv", ".mkv", ".mov", ".webm", ".m4v", ".f4v", ".3gp", ".avi", ".rmvb", ".mpg", ".mpeg", ".m3u8", ".mpd"];
private readonly string? _referer;
private readonly string? _cookie;
/// <param name="referer">播放器所在页面的地址,不是顶层页面地址(播放器常在跨域 iframe 里)。</param>
/// <param name="cookie">从 WebView2 配置文件里取出的 Cookie,部分站点没 Cookie 直接给空文件。</param>
public VideoDownloader(string? referer, string? cookie = null)
{
if (!string.IsNullOrWhiteSpace(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var u)
&& !string.IsNullOrEmpty(u.Host))
{
// 防盗链一般只校来源站点,保留完整路径反而更容易被判定为非法来源;
// 用 Authority 而不是 Host,带端口的站点把端口去掉后来源就对不上了
_referer = $"{u.Scheme}://{u.Authority}/";
}
if (!string.IsNullOrWhiteSpace(cookie)) _cookie = cookie;
}
public static bool IsHlsUrl(string url) =>
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"\.m3u8(\?|#|$)", RegexOptions.IgnoreCase);
/// <summary>
/// 判断是否是 DASH 纯音频轨地址(抖音 .../media-audio-xxx/...)。
/// 这种地址单独下载出来没有画面,必须和视频轨合并。
/// </summary>
public static bool IsDashAudioOnly(string? url) =>
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-audio[/-]", RegexOptions.IgnoreCase);
/// <summary>
/// 判断是否是 DASH 纯视频轨地址(抖音 .../media-video-xxx/...)。
/// 这种地址单独下载出来没有声音,需要配音频轨。
/// </summary>
public static bool IsDashVideoOnly(string? url) =>
!string.IsNullOrEmpty(url) && Regex.IsMatch(url, @"/media-video[/-]", RegexOptions.IgnoreCase);
/// <summary>
/// 判断是否是 HLS 分片(合并播放列表可用,但单独下载没有意义)。
/// 只认分片特有的后缀与编号形式,不能拿路径里的 video / seg 字样当依据,
/// 否则 /video/xxx.flv 这种整片地址会被当成分片删掉。
/// </summary>
public static bool IsSegmentUrl(string url)
{
if (string.IsNullOrEmpty(url)) return false;
var path = Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.AbsolutePath.ToLowerInvariant() : url.ToLowerInvariant();
var extension = Path.GetExtension(path);
// 没有扩展名的地址多为接口型直链(如抖音),当作整片
if (extension.Length == 0) return false;
if (WholeVideoExtensions.Any(e => extension == e)) return false;
return extension is ".ts" or ".m4s" or ".init" or ".m2ts" or ".aac" or ".mp3"
|| Regex.IsMatch(path, @"(seg|chunk|part|fragment|f)[-_.]?\d+");
}
public static bool LooksLikeMedia(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return false;
if (url.StartsWith("blob:", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return false;
if (!Uri.TryCreate(url, UriKind.Absolute, out var u)) return false;
if (u.Scheme != Uri.UriSchemeHttp && u.Scheme != Uri.UriSchemeHttps) return false;
var path = u.AbsolutePath.ToLowerInvariant();
if (Regex.IsMatch(path, @"\.(jpg|jpeg|png|gif|webp|ico|svg|css|js|json|woff2?|ttf|map)$")) return false;
if (MediaExtensions.Any(e => path.EndsWith(e))) return true;
return Regex.IsMatch(path, @"/(video|vod|playback|media)[/-]");
}
/// <summary>
/// 根据地址推断保存扩展名;m3u8 会合并成单个文件,有 ffmpeg 时转 mp4,否则输出 ts。
/// </summary>
public static string ExtensionFor(string url)
{
var raw = RawExtension(url);
if (raw == ".m3u8" || (raw.Length == 0 && IsHlsUrl(url))) return HasFfmpeg() ? ".mp4" : ".ts";
if (raw.Length == 0) return ".mp4";
return raw;
}
public static string RawExtension(string url)
{
try
{
return Path.GetExtension(new Uri(url).AbsolutePath).ToLowerInvariant();
}
catch
{
return "";
}
}
/// <summary>
/// 生成建议的文件名:优先用页面标题,其次用地址末段。
/// </summary>
public string SuggestedFileName(string url, string? title)
{
var name = CleanFileName(title);
if (string.IsNullOrWhiteSpace(name))
{
try
{
name = CleanFileName(Path.GetFileNameWithoutExtension(new Uri(url).AbsolutePath));
}
catch
{
// 忽略,使用兜底名
}
}
if (string.IsNullOrWhiteSpace(name)) name = "video";
if (name.Length > 60) name = name[..60];
return name + ExtensionFor(url);
}
private static string? CleanFileName(string? text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
var cleaned = Regex.Replace(text, @"[\\/:*?""<>|\r\n\t]", " ").Trim();
cleaned = Regex.Replace(cleaned, @"\s+", " ");
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
}
/// <summary>
/// 下载视频。m3u8 会自动挑选最高清晰度并顺序合并分片。
/// </summary>
public async Task<DownloadResult> DownloadAsync(string url, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
using var client = CreateClient();
if (IsHlsUrl(url))
{
progress?.Report(new DownloadProgress("解析播放列表…", null));
var playlist = await ResolvePlaylistAsync(client, url, ct);
return await WriteHlsAsync(client, playlist, filePath, progress, ct);
}
return await DownloadSingleAsync(client, url, filePath, progress, ct);
}
/// <summary>
/// 站点只用 MSE 播放、抓不到 m3u8 地址时,直接拿页面里嗅探到的播放列表文本下载。
/// </summary>
public async Task<DownloadResult> DownloadFromPlaylistTextAsync(string playlistText, string baseUrl, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
using var client = CreateClient();
progress?.Report(new DownloadProgress("解析播放列表…", null));
var playlist = await ParsePlaylistAsync(playlistText, baseUrl, client, ct, 0);
return await WriteHlsAsync(client, playlist, filePath, progress, ct);
}
/// <summary>
/// 下载 DASH 分离的视频轨和音频轨,再用 ffmpeg 合并成单个 mp4。
/// 抖音等站点把视频和音频分成两个独立的 mp4 轨道返回,必须合并才能有声有画。
/// </summary>
public async Task<DownloadResult> DownloadDashPairAsync(string videoUrl, string audioUrl, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
var ffmpeg = FindFfmpeg();
if (ffmpeg == null)
{
throw new NotSupportedException("下载抖音 DASH 分离视频轨需要 ffmpeg 合并音视频,但未找到 ffmpeg.exe。请把 ffmpeg.exe 放到程序目录下。");
}
using var client = CreateClient();
// 临时文件
var tmpDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tmpDir);
var videoTmp = Path.Combine(tmpDir, "video.mp4");
var audioTmp = Path.Combine(tmpDir, "audio.mp4");
try
{
// 下载视频轨(占总进度 0~50%)
progress?.Report(new DownloadProgress("下载视频轨…", 0));
await DownloadSingleToFileAsync(client, videoUrl, videoTmp, ct,
p => progress?.Report(new DownloadProgress($"下载视频轨 {FormatSize(p)}", null)));
var videoSize = new FileInfo(videoTmp).Length;
// 下载音频轨(占总进度 50~100%)
progress?.Report(new DownloadProgress("下载音频轨…", 0.5));
await DownloadSingleToFileAsync(client, audioUrl, audioTmp, ct,
p => progress?.Report(new DownloadProgress($"下载音频轨 {FormatSize(p)}", null)));
var audioSize = new FileInfo(audioTmp).Length;
// 合并
progress?.Report(new DownloadProgress("合并音视频…", 0.95));
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(filePath))!);
var ok = await MuxDashAsync(ffmpeg, videoTmp, audioTmp, filePath, ct);
if (!ok)
throw new InvalidOperationException("ffmpeg 合并音视频失败");
var finalSize = new FileInfo(filePath).Length;
progress?.Report(new DownloadProgress($"完成 {FormatSize(finalSize)}", 1));
return new DownloadResult(filePath, finalSize, true);
}
finally
{
try { Directory.Delete(tmpDir, true); } catch { /* 临时目录删不掉不影响结果 */ }
}
}
/// <summary>
/// 下载单个文件到指定路径(不做 .part 重命名,内部用)。
/// </summary>
private async Task DownloadSingleToFileAsync(HttpClient client, string url, string filePath, CancellationToken ct, Action<long>? onBytes = null)
{
using var resp = await GetFollowRedirectAsync(client, url, ct);
EnsureVideoResponse(resp);
await using var input = await resp.Content.ReadAsStreamAsync(ct);
await using var output = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 64, true);
var buffer = new byte[1024 * 64];
long total = 0;
int read;
while ((read = await input.ReadAsync(buffer, ct)) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), ct);
total += read;
onBytes?.Invoke(total);
}
}
/// <summary>
/// 用 ffmpeg 把 DASH 视频轨和音频轨合并成 mp4。
/// 优先两条流直接 copy:不重编码、时间戳原样保留,音画对齐最准确;
/// 只有音频轨格式装不进 mp4 容器时才回退到 AAC 转码。
/// </summary>
private static async Task<bool> MuxDashAsync(string ffmpeg, string videoFile, string audioFile, string outputFile, CancellationToken ct)
{
// 只取视频轨的视频流 + 音频轨的音频流(没音频流也照常出片),-shortest 把两条轨截齐到同一时长
var copyArgs = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{videoFile}\" -i \"{audioFile}\" -map 0:v:0 -map 1:a:0? -c copy -shortest -movflags +faststart \"{outputFile}\"";
if (await RunFfmpegAsync(ffmpeg, copyArgs, outputFile, ct)) return true;
TryDelete(outputFile);
var aacArgs = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{videoFile}\" -i \"{audioFile}\" -map 0:v:0 -map 1:a:0? -c:v copy -c:a aac -strict experimental -shortest -movflags +faststart \"{outputFile}\"";
return await RunFfmpegAsync(ffmpeg, aacArgs, outputFile, ct);
}
private static async Task<bool> RunFfmpegAsync(string ffmpeg, string args, string outputFile, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = ffmpeg,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true
};
using var proc = Process.Start(psi);
if (proc == null) return false;
using (ct.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } }))
{
await proc.WaitForExitAsync(ct);
}
return proc.ExitCode == 0 && File.Exists(outputFile) && new FileInfo(outputFile).Length > 0;
}
/// <summary>
/// 抽帧结果:成功时 Frames 非空;失败时 Error 给界面显示原因,不让用户干等。
/// </summary>
public record FrameExtraction(List<byte[]> Frames, string? Error);
/// <summary>
/// 抽取预览截图:每 intervalSeconds 秒取一张、共取 frameCount 张(默认 5 秒 3 张),
/// 供下载列表肉眼对比哪个是当前视频。用 ffmpeg 直接读远端地址,带 Referer/Cookie 过防盗链。
/// 单次尝试限 25 秒,超时不再重试第二次,避免地址失效时列表长时间卡在“生成中”。
/// </summary>
public async Task<FrameExtraction> ExtractFramesAsync(string url, CancellationToken ct, int intervalSeconds = 5, int frameCount = 3)
{
var ffmpeg = FindFfmpeg();
if (ffmpeg == null)
{
return new FrameExtraction(new List<byte[]>(), "未找到 ffmpeg,无法生成截图");
}
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return new FrameExtraction(new List<byte[]>(), "该地址无法直接抽帧");
}
var tmpDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tmpDir);
try
{
string? lastError = null;
// 先试从第 2 秒开始抽(片头常有黑帧),只有上一次正常退出且没拿到帧才退回 0 秒再试
foreach (var startAt in new[] { 2, 0 })
{
ct.ThrowIfCancellationRequested();
var args = new List<string>
{
"-hide_banner", "-loglevel", "error", "-nostdin", "-y",
// 远端卡住时快速失败:单次 I/O 12 秒,探测上限 2MB/2秒,不靠外部杀进程兑底
"-rw_timeout", "12000000",
"-probesize", "2000000", "-analyzeduration", "2000000",
"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2"
};
var headers = BuildFfmpegHeaders();
if (headers.Length > 0)
{
args.Add("-headers");
args.Add(headers);
}
if (IsHlsUrl(url))
{
args.Add("-protocol_whitelist");
args.Add("file,http,https,tcp,tls,crypto");
}
args.AddRange(new[]
{
"-ss", startAt.ToString(), "-i", url,
"-t", (intervalSeconds * frameCount).ToString(),
"-vf", $"fps=1/{intervalSeconds},scale=160:-2",
"-frames:v", frameCount.ToString(), "-an",
Path.Combine(tmpDir, "f_%d.jpg")
});
var run = await RunFfmpegArgsAsync(ffmpeg, args, ct);
var files = Directory.GetFiles(tmpDir, "f_*.jpg").OrderBy(f => f, StringComparer.Ordinal).ToList();
if (files.Count > 0)
{
return new FrameExtraction(files.Select(File.ReadAllBytes).ToList(), null);
}
lastError = run.Error ?? "没抽到帧";
if (run.TimedOut)
{
break; // 地址多半已失效或被防盗链卡住,再试一次只会多等半分钟
}
}
return new FrameExtraction(new List<byte[]>(), lastError ?? "没抽到帧");
}
finally
{
try { Directory.Delete(tmpDir, true); } catch { /* 临时目录删不掉不影响结果 */ }
}
}
/// <summary>
/// ffmpeg 走网络也要过防盗链:把下载器身上的 UA / Referer / Cookie 拼成 -headers 参数。
/// </summary>
private string BuildFfmpegHeaders()
{
var sb = new StringBuilder();
sb.Append($"User-Agent: {UserAgent}\r\n");
if (_referer != null) sb.Append($"Referer: {_referer}\r\n");
if (_cookie != null) sb.Append($"Cookie: {_cookie}\r\n");
return sb.ToString();
}
/// <summary>ffmpeg 单次运行结果:TimedOut 表示被外部杀掉,Error 给界面显示原因。</summary>
private record FfmpegRun(bool TimedOut, string? Error);
private static async Task<FfmpegRun> RunFfmpegArgsAsync(string ffmpeg, List<string> args, CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = ffmpeg,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true
};
foreach (var a in args) psi.ArgumentList.Add(a);
using var proc = Process.Start(psi);
if (proc == null) return new FfmpegRun(false, "ffmpeg 启动失败");
// 就算 -rw_timeout 失效(DNS 卡住、TLS 握手挂起等),25 秒也必须杀掉
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(25));
var errRead = proc.StandardError.ReadToEndAsync(); // 不读干 stderr 会把进程堵死
bool timedOut = false;
using (timeout.Token.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } }))
{
try
{
await proc.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // 用户取消要往上抛,超时杀掉进程则返回原因
}
catch (OperationCanceledException)
{
timedOut = true;
}
}
string stderr = string.Empty;
try { stderr = await errRead; } catch { /* 进程被杀时读流可能报错,不影响判断 */ }
if (timedOut)
{
return new FfmpegRun(true, "抽帧超时(25秒):地址可能已失效或被防盗链卡住");
}
if (proc.ExitCode != 0)
{
return new FfmpegRun(false, SummarizeFfmpegError(stderr));
}
return new FfmpegRun(false, null);
}
/// <summary>取 ffmpeg 报错里最有信息量的一行,太长截断,直接丢给界面显示。</summary>
private static string SummarizeFfmpegError(string stderr)
{
var line = (stderr ?? string.Empty)
.Split('\n')
.Select(l => l.Trim('\r', ' '))
.FirstOrDefault(l => l.Length > 0 && !l.StartsWith("[h264", StringComparison.OrdinalIgnoreCase)
&& !l.StartsWith("[aac", StringComparison.OrdinalIgnoreCase));
if (string.IsNullOrEmpty(line)) return "ffmpeg 抽帧失败";
return line.Length > 90 ? line[..90] + "…" : line;
}
private HttpClient CreateClient()
{
var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false })
{
Timeout = TimeSpan.FromMinutes(15)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
if (_referer != null) client.DefaultRequestHeaders.Referrer = new Uri(_referer);
if (_cookie != null) client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", _cookie);
return client;
}
private async Task<DownloadResult> DownloadSingleAsync(HttpClient client, string url, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
using var resp = await GetFollowRedirectAsync(client, url, ct);
EnsureVideoResponse(resp);
var total = resp.Content.Headers.ContentLength ?? -1L;
var part = filePath + ".part";
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(filePath))!);
long done = 0;
long lastReport = 0;
await using var input = await resp.Content.ReadAsStreamAsync(ct);
try
{
var buffer = new byte[1024 * 64];
// 先读一小块验货:防盗链站点经常不报 403,而是给一段 200 的 HTML 错误页
int read = await input.ReadAsync(buffer.AsMemory(0, 4096), ct);
if (read > 0) EnsureRealVideoContent(buffer, read, resp, url);
await using var output = new FileStream(part, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 64, true);
while (read > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), ct);
done += read;
if (done - lastReport >= 256 * 1024)
{
lastReport = done;
progress?.Report(total > 0
? new DownloadProgress($"下载中 {FormatSize(done)} / {FormatSize(total)}", done / (double)total)
: new DownloadProgress($"下载中 {FormatSize(done)}(总大小未知,可点取消结束)", null));
}
read = await input.ReadAsync(buffer, ct);
}
}
catch
{
TryDelete(part);
throw;
}
MoveIntoPlace(part, filePath);
progress?.Report(new DownloadProgress($"完成 {FormatSize(done)}", 1));
return new DownloadResult(filePath, done, false);
}
/// <summary>
/// 把常见 HTTP 错误码翻译成能行动的原因,只说“403”用户不知道下一步该做什么。
/// </summary>
private static void EnsureVideoResponse(HttpResponseMessage resp)
{
if (resp.IsSuccessStatusCode) return;
string hint = (int)resp.StatusCode switch
{
403 or 401 => ",防盗链或需要登录:先在页面里把视频播起来再点下载,或换一条线路",
404 => ",地址已失效(这类直链通常只有特别短的有效期)",
416 => ",服务器不接受当前请求范围,重试即可",
_ => string.Empty
};
throw new HttpRequestException($"服务器返回 {(int)resp.StatusCode} {resp.StatusCode}{hint}");
}
/// <summary>
/// 真正的视频容器不会以 "&lt;" 或 "{" 开头;m3u8 / mpd 本身是文本,不参与检查。
/// </summary>
private static void EnsureRealVideoContent(byte[] head, int length, HttpResponseMessage resp, string url)
{
if (IsHlsUrl(url) || RawExtension(url) == ".mpd") return;
var type = resp.Content.Headers.ContentType?.MediaType ?? string.Empty;
if (type.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)
|| type.StartsWith("text/plain", StringComparison.OrdinalIgnoreCase)
|| type.Contains("json", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException($"站点返回的是 {type} 而不是视频,多半是防盗链或需要先登录/先播放");
}
int i = 0;
while (i < length && (head[i] is 0xEF or 0xBB or 0xBF or 0x20 or 0x0D or 0x0A or 0x09)) i++;
if (i < length && (head[i] == (byte)'<' || head[i] == (byte)'{'))
{
throw new NotSupportedException("站点返回的是网页内容而不是视频(防盗链或地址已过期),换一条线路重试");
}
}
private static void TryDelete(string path)
{
try { if (File.Exists(path)) File.Delete(path); } catch { /* 删不掉不影响报错 */ }
}
private async Task<DownloadResult> WriteHlsAsync(HttpClient client, HlsPlaylist playlist, string filePath, IProgress<DownloadProgress>? progress, CancellationToken ct)
{
if (playlist.Segments.Count == 0)
throw new InvalidOperationException("播放列表里没有解析到视频分片,可能还在加载中,请稍后重试");
if (playlist.KeyLine != null && playlist.KeyLine.Contains("METHOD=", StringComparison.OrdinalIgnoreCase)
&& !playlist.KeyLine.Contains("METHOD=NONE", StringComparison.OrdinalIgnoreCase))
{
throw new NotSupportedException($"该视频是加密 HLS({playlist.KeyLine}),需要解密 Key,暂不支持自动下载");
}
// 分片直接拼接出来的是 .ts;装了 ffmpeg 时顺手无损转封装成 mp4,播放更通用
var wantsMp4 = string.Equals(Path.GetExtension(filePath), ".mp4", StringComparison.OrdinalIgnoreCase);
var ffmpeg = wantsMp4 ? FindFfmpeg() : null;
if (wantsMp4 && ffmpeg == null)
{
// 没有 ffmpeg 就不能把 ts 流写成 .mp4 后缀,否则很多播放器认不出来
filePath = Path.ChangeExtension(filePath, ".ts");
progress?.Report(new DownloadProgress("未找到 ffmpeg,改存为 .ts(播放器可以直接播)", null));
}
var mergedPath = ffmpeg != null
? Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".ts")
: filePath;
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(mergedPath))!);
long total = 0;
int index = 0;
var part = mergedPath + ".part";
await using (var output = new FileStream(part, FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024, true))
{
foreach (var segment in playlist.Segments)
{
ct.ThrowIfCancellationRequested();
using var resp = await GetFollowRedirectAsync(client, segment, ct);
resp.EnsureSuccessStatusCode();
await using var input = await resp.Content.ReadAsStreamAsync(ct);
await input.CopyToAsync(output, ct);
total += resp.Content.Headers.ContentLength ?? 0;
index++;
if (index % 5 == 0 || index == playlist.Segments.Count)
progress?.Report(new DownloadProgress($"下载分片 {index}/{playlist.Segments.Count},已下载 {FormatSize(total)}", index / (double)playlist.Segments.Count));
}
}
if (ffmpeg == null)
{
MoveIntoPlace(part, mergedPath);
progress?.Report(new DownloadProgress($"完成 {FormatSize(total)}", 1));
return new DownloadResult(mergedPath, new FileInfo(mergedPath).Length, false);
}
progress?.Report(new DownloadProgress("用 ffmpeg 转封装为 mp4…", null));
MoveIntoPlace(part, mergedPath);
try
{
var ok = await ConvertToMp4Async(ffmpeg, mergedPath, filePath, ct);
if (ok)
{
try { File.Delete(mergedPath); } catch { /* 临时文件删不掉不影响结果 */ }
var size = new FileInfo(filePath).Length;
progress?.Report(new DownloadProgress($"完成 {FormatSize(size)}", 1));
return new DownloadResult(filePath, size, true);
}
}
catch
{
// 转封装失败时保留 ts 文件,避免用户什么都拿不到
}
var tsSize = new FileInfo(mergedPath).Length;
progress?.Report(new DownloadProgress($"完成(未能转成 mp4,已保存 ts){FormatSize(tsSize)}", 1));
return new DownloadResult(mergedPath, tsSize, false);
}
private record HlsPlaylist(List<string> Segments, string? KeyLine);
/// <summary>
/// 解析 m3u8:主播放列表按带宽挑最高清晰度,最后返回绝对地址的分片列表。
/// </summary>
private async Task<HlsPlaylist> ResolvePlaylistAsync(HttpClient client, string url, CancellationToken ct, int depth = 0)
{
if (depth > 3) throw new InvalidOperationException("播放列表嵌套层级过深");
using var resp = await GetFollowRedirectAsync(client, url, ct);
resp.EnsureSuccessStatusCode();
var text = await ReadTextAsync(resp, ct);
return await ParsePlaylistAsync(text, url, client, ct, depth);
}
private async Task<HlsPlaylist> ParsePlaylistAsync(string text, string url, HttpClient client, CancellationToken ct, int depth)
{
if (!text.Contains("#EXTM3U"))
throw new InvalidOperationException("返回的内容不是 m3u8 播放列表,请改用列表里的 mp4 直链下载");
var lines = text.Split('\n').Select(l => l.Trim('\r', ' ', '\t')).ToList();
// 主播放列表:#EXT-X-STREAM-INF 后面紧跟子列表地址
var variants = new List<(string Uri, int Bandwidth)>();
for (int i = 0; i < lines.Count; i++)
{
if (!lines[i].StartsWith("#EXT-X-STREAM-INF", StringComparison.OrdinalIgnoreCase)) continue;
var bandwidth = 0;
var m = Regex.Match(lines[i], @"BANDWIDTH=(\d+)", RegexOptions.IgnoreCase);
if (m.Success) bandwidth = int.Parse(m.Groups[1].Value);
for (int j = i + 1; j < lines.Count; j++)
{
if (lines[j].Length == 0 || lines[j].StartsWith("#")) continue;
variants.Add((lines[j], bandwidth));
break;
}
}
if (variants.Count > 0)
{
var best = variants[^1];
foreach (var v in variants)
if (v.Bandwidth > best.Bandwidth) best = v;
ct.ThrowIfCancellationRequested();
return await ResolvePlaylistAsync(client, Resolve(url, best.Uri), ct, depth + 1);
}
var segments = lines
.Where(l => l.Length > 0 && !l.StartsWith("#"))
.Select(l => Resolve(url, l))
.ToList();
var keyLine = lines.FirstOrDefault(l => l.StartsWith("#EXT-X-KEY", StringComparison.OrdinalIgnoreCase));
return new HlsPlaylist(segments, keyLine);
}
private static string Resolve(string baseUrl, string relative)
{
if (Path.IsPathRooted(relative) || relative.StartsWith("//", StringComparison.Ordinal))
{
try
{
var u = new Uri(baseUrl);
return $"{u.Scheme}:{relative}";
}
catch
{
return relative;
}
}
if (Uri.TryCreate(relative, UriKind.Absolute, out _)) return relative;
try
{
return new Uri(new Uri(baseUrl), relative).ToString();
}
catch
{
return relative;
}
}
private static async Task<HttpResponseMessage> GetFollowRedirectAsync(HttpClient client, string url, CancellationToken ct)
{
var current = url;
HttpResponseMessage? previous = null;
for (int i = 0; i < 6; i++)
{
var resp = await client.GetAsync(current, HttpCompletionOption.ResponseHeadersRead, ct);
if (resp.IsSuccessStatusCode) return resp;
var status = (int)resp.StatusCode;
var location = resp.Headers.Location;
if (status < 300 || status > 399 || location == null) return resp;
previous?.Dispose();
previous = resp;
current = location.IsAbsoluteUri ? location.ToString() : new Uri(new Uri(current), location).ToString();
}
return previous ?? await client.GetAsync(current, HttpCompletionOption.ResponseHeadersRead, ct);
}
/// <summary>
/// m3u8 常用 GBK 编码,直接按 UTF8 读会让中文路径的分片地址出错。
/// </summary>
private static async Task<string> ReadTextAsync(HttpResponseMessage resp, CancellationToken ct)
{
using var ms = new MemoryStream();
await resp.Content.CopyToAsync(ms, ct);
var encoding = Encoding.UTF8;
var match = CharsetRx.Match(resp.Content.Headers.ContentType?.ToString() ?? "");
if (match.Success)
{
try { encoding = Encoding.GetEncoding(match.Groups[1].Value); }
catch { /* 未知编码退回 UTF8 */ }
}
var text = encoding.GetString(ms.ToArray());
// 去掉可能存在的 BOM
if (text.Length > 0 && text[0] == '\uFEFF') text = text[1..];
return text;
}
private static async Task<bool> ConvertToMp4Async(string ffmpeg, string tsFile, string mp4File, CancellationToken ct)
{
var args = $"-hide_banner -loglevel error -y -fflags +genpts -i \"{tsFile}\" -c copy -bsf:a aac_adtstoasc -movflags +faststart \"{mp4File}\"";
var psi = new ProcessStartInfo
{
FileName = ffmpeg,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true
};
using var proc = Process.Start(psi);
if (proc == null) return false;
using (ct.Register(() => { try { if (!proc.HasExited) proc.Kill(true); } catch { } }))
{
await proc.WaitForExitAsync(ct);
}
return proc.ExitCode == 0 && File.Exists(mp4File) && new FileInfo(mp4File).Length > 0;
}
/// <summary>
/// 查找 ffmpeg:顺序为 1) ffmpeg.json 配置文件指定的路径;2) 程序目录 ffmpeg.exe;3) PATH。
/// 配置文件格式:{"ffmpegPath": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe"}
/// </summary>
public static string? FindFfmpeg()
{
// 1. 配置文件指定的路径(优先级最高)
var configPath = Path.Combine(AppContext.BaseDirectory, "ffmpeg.json");
if (File.Exists(configPath))
{
try
{
var json = File.ReadAllText(configPath);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("ffmpegPath", out var prop) &&
prop.ValueKind == JsonValueKind.String)
{
var configured = prop.GetString();
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
return configured;
}
}
catch { /* 配置文件损坏就跳过,继续找别的 */ }
}
// 2. 程序目录
var local = Path.Combine(AppContext.BaseDirectory, "ffmpeg.exe");
if (File.Exists(local)) return local;
// 3. PATH
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(dir)) continue;
try
{
var candidate = Path.Combine(dir.Trim().Trim('"'), "ffmpeg.exe");
if (File.Exists(candidate)) return candidate;
}
catch
{
// PATH 里存在非法字符时跳过该项
}
}
return null;
}
public static bool HasFfmpeg() => FindFfmpeg() != null;
private static void MoveIntoPlace(string partFile, string targetFile)
{
if (File.Exists(targetFile)) File.Delete(targetFile);
File.Move(partFile, targetFile);
}
public static string FormatSize(long bytes)
{
if (bytes <= 0) return "0 B";
if (bytes >= 1L << 30) return $"{bytes / (double)(1L << 30):0.00} GB";
if (bytes >= 1L << 20) return $"{bytes / (double)(1L << 20):0.0} MB";
if (bytes >= 1L << 10) return $"{bytes / (double)(1L << 10):0.0} KB";
return $"{bytes} B";
}
}
}
+101
View File
@@ -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;
}
}
+341
View File
@@ -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;
}
}
}
}
+1
View File
@@ -0,0 +1 @@
https://www.douyin.com/jingxuan?modal_id=7704345703665422713
+1
View File
@@ -0,0 +1 @@
{"v": "https://v26-web.douyinvod.com/cde0dd644a3f3fdd8b51ce0d8ac53eea/6aed51d1/video/tos/cn/tos-cn-v-694b40/oUqAC2l4TBfIhPFJ2AcgFisAA41dgvfDqX8DEW/media-video-hvc1/?a=6383&ch=0&cr=8&dr=0&er=1&lr=default&cd=0%7C0%7C0%7C3&cv=1&br=346&bt=346&cs=4&ds=6&mime_type=video_mp4&qs=14&rc=O2RpN2doPDxnNjk1Njs1Z0BpM3l0M3U5cm40ZTYzNDVpM0A2Y2MvNDAuXmAxXmMtNjAxYSNfcGliMmRzX2VhLS1kMi9zcw%3D%3D&btag=c0000e00038000&cquery=100w_100o&dy_q=1793840674&l=20261105090434E2510E54DA3AC7D64180", "a": "https://v26-web.douyinvod.com/e6943c243c43eed40ed2797458968f85/6aed51d1/video/tos/cn/tos-cn-v-694b40/okTEuLCmAKneHtxwXVYxfsIN8ARqAA56GCfeIA/media-audio-und-mp4a/?a=6383&ch=0&cr=8&dr=0&er=1&lr=default&cd=0%7C0%7C0%7C3&cv=1&br=50&bt=50&cs=0&mime_type=video_mp4&qs=1&rc=OTVpOTw6Njo2O2U6ZWQ2NUBpM3l0M3U5cm40ZTYzNDVpM0A1MS80LWBfYy4wLTQxMjQvYSNfcGliMmRzX2VhLS1kMi9zcw%3D%3D&btag=c0000e00038000&cquery=100o_100w&dy_q=1793840674&l=20261105090434E2510E54DA3AC7D64180"}
+3
View File
@@ -0,0 +1,3 @@
{
"ffmpegPath": "D:\\NET8\\Exe\\ffmpeg\\bin\\ffmpeg.exe"
}
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.100",
"rollForward": "latestMajor"
}
}
+86 -37
View File
@@ -1,30 +1,31 @@
{
"name" : "安全小秘书",
"appid" : "__UNI__A3FEFA1",
"description" : "本地 SQLite 密码保险箱,图案密码登录",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
"name": "安全小秘书",
"appid": "__UNI__A3FEFA1",
"description": "本地 SQLite 密码保险箱,图案密码登录",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
/* 5+App特有相关 */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
/* 模块配置 */
"modules" : {
"sqlite" : {}
"modules": {
"sqlite": {},
"Camera": {}
},
/* 应用发布信息 */
"distribute" : {
"distribute": {
/* android打包配置 */
"android" : {
"permissions" : [
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
@@ -41,13 +42,17 @@
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
],
"abiFilters" : [ "arm64-v8a", "x86" ]
"abiFilters": [
"arm64-v8a",
"x86"
]
},
/* ios打包配置 */
"ios" : {
"dSYMs" : false
"ios": {
"dSYMs": false
},
/* SDK配置 */
<<<<<<< HEAD
"sdkConfigs" : {
"ad" : {},
"share" : {
@@ -59,31 +64,75 @@
},
"splashscreen" : {
"useOriginalMsgbox" : true
=======
"sdkConfigs": {
"share": {}
},
"splashscreen": {
"useOriginalMsgbox": true
},
"icons": {
"android": {
"hdpi": "unpackage/res/icons/72x72.png",
"xhdpi": "unpackage/res/icons/96x96.png",
"xxhdpi": "unpackage/res/icons/144x144.png",
"xxxhdpi": "unpackage/res/icons/192x192.png"
},
"ios": {
"appstore": "unpackage/res/icons/1024x1024.png",
"ipad": {
"app": "unpackage/res/icons/76x76.png",
"app@2x": "unpackage/res/icons/152x152.png",
"notification": "unpackage/res/icons/20x20.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"proapp@2x": "unpackage/res/icons/167x167.png",
"settings": "unpackage/res/icons/29x29.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"spotlight": "unpackage/res/icons/40x40.png",
"spotlight@2x": "unpackage/res/icons/80x80.png"
},
"iphone": {
"app@2x": "unpackage/res/icons/120x120.png",
"app@3x": "unpackage/res/icons/180x180.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"notification@3x": "unpackage/res/icons/60x60.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"settings@3x": "unpackage/res/icons/87x87.png",
"spotlight@2x": "unpackage/res/icons/80x80.png",
"spotlight@3x": "unpackage/res/icons/120x120.png"
}
}
>>>>>>> 8260c417e21af16b318dd9155b55aa00378c20a5
}
}
},
/* 快应用特有相关 */
"quickapp" : {},
"quickapp": {},
/* 小程序特有相关 */
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents" : true
"usingComponents": true
},
"mp-alipay" : {
"usingComponents" : true
"mp-alipay": {
"usingComponents": true
},
"mp-baidu" : {
"usingComponents" : true
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao" : {
"usingComponents" : true
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics" : {
"enable" : false
"uniStatistics": {
"enable": false
},
<<<<<<< HEAD
"vueVersion" : "3",
"locale" : "zh-Hans"
}
=======
"vueVersion": "3"
}
>>>>>>> 8260c417e21af16b318dd9155b55aa00378c20a5
+8 -21
View File
@@ -219,7 +219,6 @@
<text class="file-name" @click="onPickBackup(b)">{{ b.name }}</text>
<view class="file-acts">
<view class="ico ico-path" @click.stop="onShowPath(b)"></view>
<view class="ico ico-share" @click.stop="onShareFile(b)"></view>
</view>
</view>
</scroll-view>
@@ -243,7 +242,7 @@ import { listPasswords, getPassword, savePassword, deletePassword, getSessionKey
import { generatePassword } from '@/utils/crypto';
import { pickImageAsBase64 } from '@/utils/image';
// #ifdef APP-PLUS
import { exportBackupFile, shareBackupFile, listBackupFiles, readBackupFile, clearBackupFiles, pickSecFile, readBackupFromUri } from '@/utils/backup';
import { exportBackupFile, listBackupFiles, readBackupFile, clearBackupFiles, pickSecFile, readBackupFromUri } from '@/utils/backup';
// #endif
const ADVANCED = '高级';
@@ -496,9 +495,8 @@ async function onExport() {
}
try {
const dump = await getExportPlainDump();
const url = await exportBackupFile(dump, pwd);
uni.showToast({ title: '备份已生成', icon: 'none' });
await shareBackupFile(url);
await exportBackupFile(dump, pwd);
uni.showToast({ title: '备份已生成,可在导入列表查看', icon: 'none' });
} catch (e: any) {
uni.showToast({ title: e?.message || '导出失败', icon: 'none' });
}
@@ -526,15 +524,15 @@ async function onPickBackup(item: { name: string; localUrl: string }) {
}
async function onPickFromFiles() {
const uri = await pickSecFile();
if (!uri) {
uni.showToast({ title: '未选择文件', icon: 'none' });
const picked = await pickSecFile();
if (!picked.uri) {
uni.showToast({ title: picked.error || '未选择文件', icon: 'none' });
return;
}
const pwd = await askPassword('输入所选备份文件的密码');
if (pwd === null) return;
try {
const dump = await readBackupFromUri(uri, pwd);
const dump = await readBackupFromUri(picked.uri, pwd);
confirmRestore(dump, pwd);
} catch (e: any) {
uni.showToast({ title: e?.message || '恢复失败', icon: 'none' });
@@ -586,14 +584,10 @@ function onShowPath(item: { name: string; localUrl: string }) {
uni.showModal({ title: '文件路径', content: item.localUrl, showCancel: false, confirmText: '知道了' });
}
async function onShareFile(item: { name: string; localUrl: string }) {
await shareBackupFile(item.localUrl);
}
function onClearBackupFiles() {
uni.showModal({
title: '清空备份文件',
content: '将删除本机 _doc/backups 下所有 .sec 备份(已分享到外部的副本不受影响)。确定继续?',
content: '将删除本机 _doc/backups 下所有 .sec 备份(已转移到外部的副本不受影响)。确定继续?',
confirmText: '清空',
confirmColor: '#e53935',
success: async (res) => {
@@ -1053,7 +1047,6 @@ async function onSave() {
.file-acts {
display: flex;
align-items: center;
gap: 18px;
}
.ico-path {
@@ -1062,12 +1055,6 @@ async function onSave() {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%235a6472'%3E%3Cpath d='M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z'/%3E%3C/svg%3E");
}
.ico-share {
width: 20px;
height: 20px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%235a6472'%3E%3Cpath d='M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z'/%3E%3C/svg%3E");
}
.list {
flex: 1;
}
+81 -36
View File
@@ -77,23 +77,44 @@ export async function exportBackupFile(
w.write(content);
}, rej);
});
return fileEntry.toLocalURL ? fileEntry.toLocalURL() : fileEntry.fullPath;
// 返回 _doc 相对路径:可跨设备(真机/模拟器包名不同)
return `_doc/backups/${name}`;
}
/** 调起系统分享,把备份文件另存到网盘/微信收藏等(失败静默,不影响已生成文件) */
export function shareBackupFile(localUrl: string): Promise<void> {
const p = plusAny();
return new Promise((resolve) => {
try {
p.share.sendWithSystem(
{ type: "file", files: [localUrl] },
() => resolve(),
() => resolve(),
);
} catch {
resolve();
/** 用 Java 文件 IO 读取绝对路径文本(不经过 content resolver,真实机型可用) */
function readTextByNativePath(p: any, path: string): string {
const File = p.android.importClass("java.io.File");
const FileInputStream = p.android.importClass("java.io.FileInputStream");
const fis = new FileInputStream(new File(path));
try {
let out = "";
let b = fis.read();
// 备份文件为 JSON 文本且体积很小,逐字节读取避免跨语言 byte[] 桥接问题
while (b !== -1) {
out += String.fromCharCode(b);
b = fis.read();
}
});
// Latin-1 逐字节拼接后还原 UTF-8 再解码
return decodeURIComponent(escape(out));
} finally {
fis.close();
}
}
/** 通过 content resolver 输入流读取文本 */
function readTextByStream(p: any, is: any): string {
const BufferedReader = p.android.importClass("java.io.BufferedReader");
const InputStreamReader = p.android.importClass("java.io.InputStreamReader");
const reader = new BufferedReader(new InputStreamReader(is));
let out = "";
let line = reader.readLine();
while (line != null) {
out += line;
line = reader.readLine();
if (line != null) out += "\n";
}
reader.close();
return out;
}
/** 列出 _doc/backups 下的备份文件 */
@@ -107,7 +128,8 @@ export async function listBackupFiles(): Promise<BackupItem[]> {
.filter((e) => e.name && e.name.endsWith(".sec"))
.map((e) => ({
name: e.name,
localUrl: e.toLocalURL ? e.toLocalURL() : e.fullPath,
// 统一用 _doc 相对路径,跨设备(包名不同)保持稳定
localUrl: `_doc/backups/${e.name}`,
}));
}
@@ -169,10 +191,27 @@ export async function clearBackupFiles(): Promise<number> {
return targets.length;
}
/** 调起系统文件选择器,返回所选文件的 content URI(取消或失败返回 null) */
export function pickSecFile(): Promise<string | null> {
/** 调起系统文件选择器,返回所选文件(content URI 或绝对路径);取消/失败返回 null,异常信息在 error */
export function pickSecFile(): Promise<{ uri: string | null; error?: string }> {
const p = plusAny();
return new Promise((resolve) => {
let settled = false;
const done = (r: { uri: string | null; error?: string }) => {
if (!settled) {
settled = true;
resolve(r);
}
};
// 优先 HTML5+ 原生选择器:不依赖 activity result 回传,真实机型稳定可用
if (typeof p.io.chooseFile === "function") {
p.io.chooseFile(
(path: string) => done({ uri: path }),
(err: any) =>
done({ uri: null, error: err?.message || "文件选择失败" }),
{ extension: ".sec" },
);
return;
}
try {
const main = p.android.runtimeMainActivity();
const Intent = p.android.importClass("android.content.Intent");
@@ -189,9 +228,9 @@ export function pickSecFile(): Promise<string | null> {
if (requestCode === REQUEST) {
if (resultCode === -1 && data) {
const uri = data.getData();
resolve(uri ? uri.toString() : null);
done({ uri: uri ? uri.toString() : null });
} else {
resolve(null);
done({ uri: null });
}
return;
}
@@ -201,13 +240,18 @@ export function pickSecFile(): Promise<string | null> {
Intent.createChooser(intent, "选择备份文件"),
REQUEST,
);
} catch {
resolve(null);
// 新版运行时结果不回传 JS 钩子,超时兜底解除挂起
setTimeout(
() => done({ uri: null, error: "文件选择器无响应" }),
5 * 60 * 1000,
);
} catch (e: any) {
done({ uri: null, error: e?.message || "调起文件选择器失败" });
}
});
}
/** 读取 content URI 文本并用密码解密(供“从文件选择 .sec”恢复) */
/** 读取所选文件(content URI 或绝对路径)并用密码解密 */
export async function readBackupFromUri(
uriStr: string,
password: string,
@@ -215,25 +259,26 @@ export async function readBackupFromUri(
const p = plusAny();
const content: string = await new Promise((resolve, reject) => {
try {
if (!uriStr.startsWith("content://")) {
// chooseFile 返回的是本机文件路径:解析为绝对路径后用原生 IO 读取
let path = uriStr;
try {
path = p.io.convertLocalFileSystemURL(uriStr) || uriStr;
} catch {
// 已是绝对路径,转换失败时沿用原值
}
path = path.replace(/^file:\/\//, "");
const File = p.android.importClass("java.io.File");
if (!new File(path).exists()) throw new Error("找不到所选文件");
resolve(readTextByNativePath(p, path));
return;
}
const main = p.android.runtimeMainActivity();
const Uri = p.android.importClass("android.net.Uri");
const uri = Uri.parse(uriStr);
const resolver = main.getContentResolver();
const is = resolver.openInputStream(uri);
const BufferedReader = p.android.importClass("java.io.BufferedReader");
const InputStreamReader = p.android.importClass(
"java.io.InputStreamReader",
);
const reader = new BufferedReader(new InputStreamReader(is));
let out = "";
let line = reader.readLine();
while (line != null) {
out += line;
line = reader.readLine();
if (line != null) out += "\n";
}
reader.close();
resolve(out);
resolve(readTextByStream(p, is));
} catch (e) {
reject(e instanceof Error ? e : new Error("读取所选文件失败"));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB