超碰人人人人人,亚洲AV午夜福利精品一区二区,亚洲欧美综合区丁香五月1区,日韩欧美亚洲系列

LOGO OA教程 ERP教程 模切知識(shí)交流 PMS教程 CRM教程 開發(fā)文檔 其他文檔  
 
網(wǎng)站管理員

PC軟件開發(fā)新體驗(yàn)!用 Blazor Hybrid 打造簡(jiǎn)潔高效的視頻處理工具

freeflydom
2025年1月17日 10:18 本文熱度 406

前言#

國(guó)慶假期各種活動(dòng)比較多,直到上班才有時(shí)間來更新文章~

不過這兩天我還是做了個(gè)小玩意(Clipify),起因是想給之前開發(fā)來自己用的簡(jiǎn)單視頻剪輯工具 QuickCutSharp 加個(gè)功能,不過這個(gè)軟件是基于 WinForms 開發(fā)的,做界面得拖拉控件,感覺繁瑣又不靈活,于是索性重新做一個(gè)。

原有代碼是C#,于是我就繼續(xù)在這個(gè)生態(tài)里尋找開發(fā)方案,Avalonia、MAUI等都是不錯(cuò)的選擇,前者我之前用過,做了個(gè)簡(jiǎn)單的圖片管理工具,后者聽說是微軟新推出的跨平臺(tái)開發(fā)方案,我這次也試了一下,不過單純處理環(huán)境就比較復(fù)雜了,直接勸退。

接下來我就把目光瞄準(zhǔn)了類似 Electron 這類套殼開發(fā),既然要用前端技術(shù)開發(fā)軟件界面,那么 C# 生態(tài)的 Blazor 就可以拿出來了,我之前也用 Blazor 開發(fā)過幾個(gè)項(xiàng)目,感覺使用 Blazor 搭配 TailwindCSS 應(yīng)該可以有不錯(cuò)的開發(fā)體驗(yàn)。

說干就干,我選擇了 Blazor Hybrid 這個(gè)方向,然后宿主容器依然選擇 WinForms,原因是暫時(shí)沒有跨平臺(tái)的需求,而且 Blazor Hybrid 目前也沒有比較好的跨平臺(tái)方案,雖然有 MAUI 但太重而且也不支持 Linux…


項(xiàng)目已經(jīng)開源,Github: https://github.com/Deali-Axy/clipify

一些截圖#

老規(guī)矩前面先放一些截圖,軟件的功能直接看圖就清楚了。

軟件主頁(yè)

提取音頻界面

導(dǎo)出視頻界面

PS:目前只實(shí)現(xiàn)了部分功能

主要技術(shù)

正如前言說到的,使用了 Blazor Hybrid 來開發(fā),那么界面就是 Blazor 實(shí)現(xiàn)的,然后運(yùn)行在一個(gè) Winforms 軟件的 BlazorWebView 中。

視頻相關(guān)的功能是調(diào)用了 ffmpeg (實(shí)際上在沒有這個(gè)軟件之前,我都是手動(dòng)輸入命令操作的…)

  • Microsoft.AspNetCore.Components.WebView.WindowsForms - 微軟官方的 Blazor Hybrid 方案,可以依托 WinForms 運(yùn)行 Blazor

  • MediatR - C#版的EventBus,用于實(shí)現(xiàn)瀏覽器和WinForms的通信

  • xFFmpeg.NET - 用于簡(jiǎn)化 ffmpeg 的調(diào)用(實(shí)際上這個(gè)庫(kù)已經(jīng)停更兩三年了,很多功能只能自己去實(shí)現(xiàn),我甚至打算fork一個(gè)來適配新版ffmpeg)

  • Microsoft.Extensions.Logging - 日志組件,沒啥好說的,AspNetCore項(xiàng)目里的???/p>

  • AntDesign - 一些組件不想自己封裝(如modal和message)就用這個(gè)

前端方面依然是 pnpm、gulp、tailwindcss、flowbite、fortawesome 這些

關(guān)于 Blazor Hybrid

Electron技術(shù)大家都很熟悉了,現(xiàn)在連QQ都用Electron重構(gòu)了,在開發(fā)了這個(gè)項(xiàng)目之后,我也能理解這種做法,用前端技術(shù)來寫界面真的爽,只要稍微犧牲一下性能,就可以獲得不錯(cuò)的效果,而且現(xiàn)在電腦的性能都已經(jīng)足夠了,正好給web技術(shù)上桌面提供了條件。

而 Blazor 對(duì)于 C# 開發(fā)人員的優(yōu)勢(shì)是不需要學(xué)習(xí)各種 JavaScript 框架就可以開發(fā)交互式的 web 應(yīng)用;雖然我做過不少前端項(xiàng)目,React也用得比較熟了,不過 Blazor Hybrid 還有一個(gè)優(yōu)勢(shì)是可以直接使用 C# 調(diào)用系統(tǒng)功能,Blazor Hybrid 一方面是運(yùn)行在瀏覽器中,一方面又是直接在操作系統(tǒng)層面運(yùn)行,C# 代碼可以不受瀏覽器沙箱的限制,直接訪問系統(tǒng)文件、設(shè)備等(雖然本項(xiàng)目中還是用到了Blazor與WinForms通信,不過那不是 C# 的功能限制,而是必須用到 WinForms 的功能)。

創(chuàng)建 Blazor Hybrid 項(xiàng)目

創(chuàng)建一個(gè)基于 WinForms 的 Blazor Hybrid 項(xiàng)目很簡(jiǎn)單,首先是創(chuàng)建 .NetCore(.Net8) 的 WinForms 項(xiàng)目,然后添加 Microsoft.AspNetCore.Components.WebView.WindowsForms 依賴

接著把 BlazorWebView 組件添加到 Form 上面

然后開始寫代碼初始化

public partial class FormMain : Form {
  public FormMain() {
    InitializeComponent();
    var services = new ServiceCollection();
    services.AddLogging(c => {
      c.AddDebug();
      c.AddFilter("Microsoft.AspNetCore.Components.WebView", LogLevel.Trace);
    });
    services.AddAntDesign();
    services.AddMediatR(cfg => { cfg.RegisterServicesFromAssemblyContaining<FormMain>(); });
    services.AddWindowsFormsBlazorWebView();
    #if DEBUG
    services.AddBlazorWebViewDeveloperTools();
    #endif
    services.AddSingleton(this);
    services.AddScoped<IHostingEnvironment, HostingEnvironment>();
    services.AddScoped<DialogService>();
    services.AddScoped<VideoService>();
    blazorWebView1.HostPage = "wwwroot\\index.html";
    blazorWebView1.Services = services.BuildServiceProvider();
    blazorWebView1.RootComponents.Add<App>("#app");
  }
}

關(guān)鍵的就在于最下面的三行代碼,設(shè)置主頁(yè)、把服務(wù)容器綁定的 Blazor 控件上,設(shè)置根組件。

然后其他的就和普通的 Blazor 項(xiàng)目一樣。

搭建項(xiàng)目基礎(chǔ)架構(gòu)

本文限于篇幅,只能簡(jiǎn)單介紹一下。

想要進(jìn)一步了解的同學(xué)可以看官網(wǎng)的指引文檔和實(shí)例項(xiàng)目。

不過微軟官網(wǎng)關(guān)于這方面的文檔也不是很詳細(xì),只是淺嘗輒止,很多內(nèi)容要靠自己摸索。

index.html

按需添加了各種 css 和 js 引用

<!DOCTYPE html><html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>Clipify</title>
    <base href="/"/>
    <link href="css/app.css" rel="stylesheet"/>
    <link href="css/tailwind.min.css" rel="stylesheet"/>
    <link href="lib/font-awesome/css/all.min.css" rel="stylesheet">
    <link href="_content/AntDesign/css/ant-design-blazor.css" rel="stylesheet" />
    <link href="Clipify.Forms.styles.css" rel="stylesheet"/>
  </head>
  <body>
    <div id="app">Loading...</div>
    <div id="blazor-error-ui" data-nosnippet>
      An unhandled error has occurred.
      <a href="" class="reload">Reload</a>
      <a class="dismiss">??</a>
    </div>
    <script src="_framework/blazor.webview.js"></script>
    <script src="lib/flowbite/flowbite.min.js"></script>
    <script src="_content/AntDesign/js/ant-design-blazor.js"></script>
    <script>
      window.initializeFlowbite = () => {
        initFlowbite();
      }
    </script>
  </body></html>

App.razor

這個(gè)是根組件

<Router AppAssembly="@typeof(App).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
        <FocusOnNavigate RouteData="@routeData" Selector="h1"/>
    </Found>
    <NotFound>
        <PageTitle>Not found</PageTitle>
        <LayoutView Layout="@typeof(MainLayout)">
            <p role="alert">Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound></Router><AntContainer />

MainLayout.razor

布局組件。

@inherits LayoutComponentBase@inject IJSRuntime Js<PageTitle>Clipify</PageTitle><button data-drawer-target="logo-sidebar" data-drawer-toggle="logo-sidebar" aria-controls="logo-sidebar" type="button" class="inline-flex items-center p-2 mt-2 ms-3 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
    <span class="sr-only">Open sidebar</span>
    <svg class="w-6 h-6" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
        <path clip-rule="evenodd" fill-rule="evenodd" d="M2 4.75A.75.75 0 012.75 4h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 4.75zm0 10.5a.75.75 0 01.75-.75h7.5a.75.75 0 010 1.5h-7.5a.75.75 0 01-.75-.75zM2 10a.75.75 0 01.75-.75h14.5a.75.75 0 010 1.5H2.75A.75.75 0 012 10z"></path>
    </svg></button><aside id="logo-sidebar" class="fixed top-0 left-0 z-40 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0" aria-label="Sidebar">
    <Navbar/></aside><div class="p-4 sm:ml-64">
    @Body</div>@code {
    protected override async Task OnAfterRenderAsync(bool isFirstRender) {#if DEBUG
        await Js.InvokeVoidAsync("window.initializeFlowbite");#endif
        if (isFirstRender) {
            await Js.InvokeVoidAsync("window.initializeFlowbite");
        }
    }}

基礎(chǔ)功能到這里就搞定了

我習(xí)慣在項(xiàng)目里加一個(gè) RouterMap ,這樣在路由跳轉(zhuǎn)的時(shí)候比較方便。

namespace Clipify.Forms;
public static class RouterMap {
    public const string Index = "/";
    public const string VideoSplit = "/video-split";
    public const string ExtractAudio = "/extract-audio";
}

導(dǎo)航欄

導(dǎo)航欄的完整代碼省略了,有興趣的同學(xué)之間在 Github 上看完整代碼吧。

這里記錄一個(gè)老生常談的問題,如何高亮當(dāng)前菜單?

有兩種方式:

  • NavigationManager 獲取當(dāng)前路徑

  • NavLink組件

在本文中我使用的是 NavLink 組件,類似這樣:

當(dāng)路徑與菜單的 href 相同時(shí),元素會(huì)自動(dòng)加上 ActiveClass 里的 class,從而實(shí)現(xiàn)高亮當(dāng)前菜單的效果。

<NavLink href="@RouterMap.ExtractAudio" ActiveClass="bg-gray-200">
  <i class="fa-solid fa-music"></i>
  <span>提取音頻</span></NavLink>

因?yàn)槠P(guān)系省略了 TailwindCSS 的 class

使用 MediatR 實(shí)現(xiàn)內(nèi)部通信

目前是把 MediatR 用在了對(duì)話框的數(shù)據(jù)交互上。

因?yàn)橐幚硪曨l,所以需要一個(gè)打開文件的對(duì)話框,和一個(gè)選擇輸出目錄的對(duì)話框。

Blazor 組件是運(yùn)行在瀏覽器里的,瀏覽器自然也能打開文件,不過打開后程序只能拿到文件的 stream ,而我需要拿到文件在電腦里的存儲(chǔ)路徑,用于調(diào)用 ffmpeg 命令進(jìn)行處理。

這種情況下只能使用 WinForms 的對(duì)話框控件了,Blazor 組件與 WinForms 處在同個(gè)進(jìn)程,這種情況下,使用 MediatR 這類進(jìn)程內(nèi)消息隊(duì)列就很合適了。

MediatR 支持兩種類型的消息,分別是

  • Request/response messages, dispatched to a single handler

  • Notification messages, dispatched to multiple handlers

一種是一對(duì)一,另一種是一對(duì)多。

我的用法是這樣:

  • Blazor組件里請(qǐng)求打開對(duì)話框,使用 request/response 一對(duì)一模式

  • 對(duì)話框選擇完通知 Blazor 組件,使用一對(duì)多的 Notification 模式

封裝 Service

為了屏蔽細(xì)節(jié)和解耦,我封裝了 DialogService,這樣做的好處是可以進(jìn)一步簡(jiǎn)化組件與 MediatR 之間的通信,確保所有與文件對(duì)話框相關(guān)的邏輯集中在一個(gè)地方,使代碼更具可維護(hù)性和一致性。

public class DialogService {
  private readonly IMediator _mediator;
  public event Func<string, Task>? OnFileSelected;
  public event Func<string, Task>? OnDirSelected;
  public DialogService(IMediator mediator) {
    _mediator = mediator;
  }
  
  public async Task<string> OpenFileAsync() {
    return await _mediator.Send(new OpenFileRequest());
  }
  public async Task<string> OpenDirAsync() {
    return await _mediator.Send(new OpenDirRequest());
  }
  public void NotifyFileSelected(string path) {
    OnFileSelected?.Invoke(path);
  }
  public void NotifyDirSelected(string path) {
    OnDirSelected?.Invoke(path);
  }
}

其中有兩個(gè)事件,分別是打開文件和選擇目錄。這樣設(shè)計(jì)的好處有幾點(diǎn):

  • 集中管理:所有與文件對(duì)話框相關(guān)的邏輯都封裝在 DialogService,包括 MediatR 的請(qǐng)求和處理。這樣可以在一個(gè)地方輕松維護(hù)代碼,提高可讀性和可維護(hù)性。

  • 松耦合:Blazor 組件不需要知道 MediatR 的細(xì)節(jié),只需與服務(wù)進(jìn)行簡(jiǎn)單的交互,符合單一職責(zé)原則。MediatR 的調(diào)用邏輯被隱藏在服務(wù)中,不會(huì)污染其他部分的代碼。

  • 便于測(cè)試:通過將 MediatR 的調(diào)用封裝到服務(wù)中,你可以更容易地測(cè)試服務(wù)邏輯和 MediatR 的交互,而不需要在 Blazor 組件中進(jìn)行復(fù)雜的測(cè)試。

以打開文件為例。

一對(duì)一的 Request

代碼 Clipify.Forms/EventBus/Request/OpenFileRequest.cs

using Clipify.Forms.EventBus.Notification;
using MediatR;
namespace Clipify.Forms.EventBus.Request;
public class OpenFileRequest : IRequest<string> { }
public class OpenFileHandler : IRequestHandler<OpenFileRequest, string> {
  private readonly IMediator _mediator;
  private readonly FormMain _formMain;
  public OpenFileHandler(FormMain formMain, IMediator mediator) {
    _formMain = formMain;
    _mediator = mediator;
  }
  public Task<string> Handle(OpenFileRequest request, CancellationToken cancellationToken) {
    var result = _formMain.openFileDialog.ShowDialog();
    if (result == DialogResult.OK) {
      var path = _formMain.openFileDialog.FileName;
      _mediator.Publish(new FileSelectedNoti {
        SelectedPath = path
      }, cancellationToken);
      return Task.FromResult(path);
    }
    return Task.FromResult("");
  }
}

收到 Request 之后,RequestHandler 里通過依賴注入拿到 MainForm 的實(shí)例,然后調(diào)用對(duì)話框拿到文件路徑,再發(fā)送通知。

一對(duì)多的 Notification

代碼 Clipify.Forms/EventBus/Notification/FileSelectedNoti.cs

PS:其實(shí)也可以使用 Request 的返回值來拿到文件路徑,不過我還是”多此一舉“使用了 Notification

using Clipify.Forms.Services;
using MediatR;
namespace Clipify.Forms.EventBus.Notification;
public class FileSelectedNoti : INotification {
  public string SelectedPath { get; set; }
}
public class FileSelectedHandler : INotificationHandler<FileSelectedNoti> {
  private readonly DialogService _dialogService;
  public FileSelectedHandler(DialogService dialogService) {
    _dialogService = dialogService;
  }
  public Task Handle(FileSelectedNoti notification, CancellationToken cancellationToken) {
    _dialogService.NotifyFileSelected(notification.SelectedPath);
    return Task.CompletedTask;
  }
}

這個(gè)代碼很簡(jiǎn)單,就是調(diào)用了 DialogService 的事件處理器。

與 ffmpeg 交互

在開發(fā) Clipify 工具時(shí),視頻處理的核心依賴于 ffmpeg,這是一款強(qiáng)大的多媒體處理工具。為了實(shí)現(xiàn)視頻剪輯、音頻提取等功能,我探索了多種與 ffmpeg 交互的方式,包括使用現(xiàn)有的 C# 庫(kù)以及直接通過系統(tǒng)進(jìn)程調(diào)用 ffmpeg。

經(jīng)過研究,可以用這幾種方式來實(shí)現(xiàn)。

  • FFmpeg.NET - 之前 QuickCutSharp 就是用這個(gè)實(shí)現(xiàn)的

  • FFMpegCore - GitHub上的star比較多

  • 直接 Process 調(diào)用

前兩種都是用第三方庫(kù),我就不太多介紹了,有興趣的同學(xué)直接看官方文檔就行。另外提一點(diǎn),C# 這邊的生態(tài)還是差了點(diǎn),就算是1k多star的FFMpegCore也沒啥文檔,只有一個(gè)項(xiàng)目的 README;前面那個(gè) FFmpeg.NET 就更不用說了,已經(jīng)停更了,而且文檔有些代碼和實(shí)際使用還對(duì)不上。

不過這些都是對(duì)于 ffmpeg 的調(diào)用,自己實(shí)現(xiàn)也是沒問題的。下面是簡(jiǎn)單的例子:

Process ffmpegProcess = new Process();
ffmpegProcess.StartInfo.FileName = "ffmpeg";
ffmpegProcess.StartInfo.Arguments = "-i input.mp4 -progress pipe:1 -f mp4 output.mp4";
ffmpegProcess.StartInfo.RedirectStandardOutput = true;
ffmpegProcess.StartInfo.UseShellExecute = false;
ffmpegProcess.StartInfo.CreateNoWindow = true;
ffmpegProcess.OutputDataReceived += (sender, e) => {
  if (!string.IsNullOrEmpty(e.Data)) {
    // 處理標(biāo)準(zhǔn)輸出中的進(jìn)度信息
    Console.WriteLine(e.Data);
    // 可以在這里解析 e.Data 以提取進(jìn)度
  }
};
ffmpegProcess.Start();
ffmpegProcess.BeginOutputReadLine();
ffmpegProcess.WaitForExit();

參數(shù)說明:

  • -progress pipe:1:表示將進(jìn)度信息輸出到標(biāo)準(zhǔn)輸出(stdout,即控制臺(tái))。FFmpeg 將輸出一系列結(jié)構(gòu)化的鍵值對(duì),表示當(dāng)前進(jìn)度的狀態(tài)。

  • pipe:1:是 FFmpeg 中表示標(biāo)準(zhǔn)輸出流的方式,pipe:0 表示標(biāo)準(zhǔn)輸入(stdin),pipe:1 表示標(biāo)準(zhǔn)輸出(stdout),pipe:2 表示標(biāo)準(zhǔn)錯(cuò)誤(stderr)。

在 ffmpeg 的參數(shù)里加上 -progress pipe:1 ,F(xiàn)Fmpeg 會(huì)輸出類似于以下內(nèi)容的進(jìn)度信息:

frame=1000
fps=24.0
stream_0_0_q=28.0
bitrate=456.8kbits/s
total_size=1024000
out_time_us=42000000
out_time_ms=42000
out_time=00:00:42.000000
dup_frames=0
drop_frames=0
speed=2.00x
progress=continue

這樣就可以簡(jiǎn)單的獲取更詳細(xì)的視頻處理進(jìn)度信息。

不過 FFmpeg.NET 的 onData 事件是無(wú)法獲取這段信息的,一般會(huì)獲取到類似這樣的輸出:

size=   16522KiB time=00:21:19.01 bitrate= 105.8kbits/s speed=68.9x

就算添加了參數(shù),也只能獲取這一行的信息,所以要詳細(xì)信息的話只能自己調(diào)用 Process 來處理。

并且 FFmpeg.NET 的 OnProgress 事件是有問題的,只能獲取到 ProcessedDuration 信息,其他的都沒辦法了,不知道是不是版本太老,不匹配新版 ffmpeg ,如果有需要可以自己寫正則解析一下。

// 使用正則表達(dá)式提取各項(xiàng)信息string sizePattern = @"size=\s*(\d+)(\w+)";string timePattern = @"time=(\d{2}:\d{2}:\d{2}\.\d{2})";string bitratePattern = @"bitrate=\s*(\d+\.\d+|\d+)(\w+)";string speedPattern = @"speed=\s*(\d+\.\d+|\d+)x";

縮略圖

在 Clipify 中,視頻縮略圖是幫助用戶快速預(yù)覽視頻的重要功能。

在本項(xiàng)目的開發(fā)中,我探索了幾種不同的縮略圖策略:

  • 視頻文件的 MD5 - 如果視頻文件較大且頻繁進(jìn)行哈希計(jì)算,可能會(huì)帶來一定的性能開銷

  • 文件路徑 MD5 - 如果文件路徑改變了(例如文件移動(dòng)或重命名),盡管文件內(nèi)容未變,MD5 仍然會(huì)不同,導(dǎo)致生成新的縮略圖。這可能會(huì)造成不必要的重復(fù)生成縮略圖。

  • 結(jié)合文件的其他屬性(如文件名、修改時(shí)間等)進(jìn)行 MD5 計(jì)算 - 這種方式可以兼顧路徑變化和文件唯一性的平衡,進(jìn)一步減少重復(fù)縮略圖的生成

為了避免重復(fù)生成縮略圖,我采用了基于 MD5 哈希的策略為每個(gè)視頻生成唯一的縮略圖文件名。這樣可以確保同一視頻即使在不同時(shí)間被訪問,仍然可以使用緩存的縮略圖,提升性能。

這部分代碼集成在 VideoService 里面。

生成縮略圖的代碼

使用了 FFmpeg.NET 提供的生成縮略圖功能(其實(shí)就是調(diào)用ffmpeg對(duì)視頻進(jìn)行截圖),根據(jù)規(guī)則生成文件名,之后把縮略圖文件保存到 wwwroot/temp/thumbnails 目錄里面。

public async Task<string> GenerateThumbnailAsync(string videoPath, CancellationToken? cancellationToken = null) {
  var inputFile = new InputFile(videoPath);
  var tempThumbnailDir = Path.Combine(_environment.WebRootPath, "temp", "thumbnails");
  if (!Directory.Exists(tempThumbnailDir)) {
    Directory.CreateDirectory(tempThumbnailDir);
  }
  var filename = $"{GetFileMetadataMd5(videoPath)}.jpeg";
  var outputPath = Path.Combine(tempThumbnailDir, filename);
  var outputFile = new OutputFile(outputPath);
  var opt = new ConversionOptions {
    HideBanner = true,
    HWAccelOutputFormatCopy = true,
    MapMetadata = true,
  };
  if (!File.Exists(outputPath)) {
    await FFmpeg.GetThumbnailAsync(inputFile, outputFile, cancellationToken ?? CancellationToken.None);
  }
  return $"temp/thumbnails/{filename}";
}

視頻文件的 MD5 哈希

最直接的方式是對(duì)整個(gè)視頻文件進(jìn)行 MD5 哈希運(yùn)算,將其生成的哈希值作為縮略圖的文件名。然而,如果視頻文件較大,頻繁進(jìn)行哈希計(jì)算可能帶來顯著的性能開銷。

public static string GetFileMd5(string filePath) {
  using var md5 = MD5.Create();
  using var stream = File.OpenRead(filePath);
  var hash = md5.ComputeHash(stream);
  return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}

優(yōu)點(diǎn):文件內(nèi)容唯一性強(qiáng),可以確保不同內(nèi)容的視頻不會(huì)生成相同的縮略圖。

缺點(diǎn):對(duì)于大型文件,MD5 計(jì)算耗時(shí)較長(zhǎng),影響性能。實(shí)測(cè)幾個(gè)G的視頻要花好幾秒的時(shí)間。

文件路徑的 MD5 哈希

為了提高性能,也可以僅對(duì)文件路徑進(jìn)行 MD5 計(jì)算。這種方式大大減少了計(jì)算量,適用于那些文件內(nèi)容不變但需要頻繁生成縮略圖的場(chǎng)景。然而,當(dāng)文件被移動(dòng)或重命名時(shí),盡管視頻內(nèi)容沒有變化,生成的 MD5 值會(huì)不同,可能導(dǎo)致不必要的重復(fù)縮略圖生成。

string filePathHash;
using (var md5 = MD5.Create()) {
  var pathBytes = Encoding.UTF8.GetBytes(videoFilePath);
  var hash = md5.ComputeHash(pathBytes);
  filePathHash = BitConverter.ToString(hash).Replace("-", "").ToLower();
}

優(yōu)點(diǎn):高效,MD5 計(jì)算速度極快,適合頻繁使用。

缺點(diǎn):文件路徑變動(dòng)時(shí),即使文件內(nèi)容不變,仍會(huì)生成新縮略圖,可能導(dǎo)致冗余的縮略圖生成。

結(jié)合文件屬性進(jìn)行 MD5 計(jì)算

為了在路徑變化和文件內(nèi)容唯一性之間找到平衡,Clipify 還可以結(jié)合文件的其他屬性,如文件名、修改時(shí)間等進(jìn)行 MD5 計(jì)算。這樣即使文件路徑發(fā)生變化,只要文件內(nèi)容和其屬性不變,MD5 也不會(huì)變化,避免不必要的重復(fù)生成。

public static string GetFileMetadataMd5(string filePath) {
  var fileName = Path.GetFileName(filePath);
  var fileInfo = new FileInfo(filePath);
  var metaData = fileName + fileInfo.LastWriteTimeUtc.ToString();
  using var md5 = MD5.Create();
  var metaBytes = System.Text.Encoding.UTF8.GetBytes(metaData);
  var hash = md5.ComputeHash(metaBytes);
  return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}

優(yōu)點(diǎn)

  • 兼顧了文件內(nèi)容的唯一性和文件路徑的變化。

  • 減少了重復(fù)縮略圖生成的情況。

缺點(diǎn):需要結(jié)合多個(gè)文件屬性,計(jì)算稍微復(fù)雜,但仍能有效提升性能。

小結(jié)

在 Clipify 中,選擇如何生成視頻縮略圖的哈希值需要在性能和唯一性之間做平衡。

對(duì)于較大的視頻文件,直接對(duì)文件進(jìn)行 MD5 計(jì)算雖然保證了內(nèi)容的唯一性,但對(duì)性能影響較大。

而通過結(jié)合文件路徑和文件屬性來生成哈希值,可以減少性能消耗并避免冗余的縮略圖生成。

在后續(xù)的版本中,可以考慮小文件使用文件內(nèi)容生成MD5,大文件繼續(xù)用綜合路徑和屬性的方式來生成MD5。

顯示視頻導(dǎo)出進(jìn)度

目前是用 FFmpeg.Net 的 OnProgress 事件,保留小數(shù)點(diǎn)后兩位

private async void OnProgress(object? sender, ConversionProgressEventArgs e) {
  Status.Status = StatusEnum.Running;
  Status.Progress = Math.Round(e.ProcessedDuration.TotalSeconds / MetaData.Duration.TotalSeconds * 100, 2);
  await InvokeAsync(StateHasChanged);
}

如果要更詳細(xì)的顯示處理時(shí)的其他信息,可以參考前面的與FFmpeg交互部分。

細(xì)節(jié)

在 Clipify 的設(shè)計(jì)過程中,我非常注重用戶體驗(yàn)中的細(xì)節(jié),尤其是如何讓用戶更直觀、輕松地理解視頻文件的屬性。因此,除了基本的視頻編輯功能,我還在界面上優(yōu)化了文件大小和視頻長(zhǎng)度的顯示方式。

本文選擇了這兩點(diǎn)來介紹:

  • 顯示更友好的文件大小

  • 顯示更友好的視頻長(zhǎng)度

顯示更友好的文件大小

視頻文件通常較大,直接顯示以字節(jié)(bytes)為單位的大小可能不夠直觀。為了提升用戶體驗(yàn),我選擇了將文件大小轉(zhuǎn)換為更常見的單位,如 KB、MB 或 GB,并使用四舍五入讓顯示更簡(jiǎn)潔。

例如,如果視頻文件大小為 3,304,582 字節(jié),則會(huì)顯示為 3.30 MB。這樣一來,用戶不需要進(jìn)行單位換算,直接可以看到文件的大致大小。

這里我寫了一個(gè)擴(kuò)展方法來實(shí)現(xiàn)。

public static class FileInfoExtensions {
  public static string GetFriendlySize(this FileInfo fileInfo) {
    string[] sizeUnits = { "Bytes", "KB", "MB", "GB", "TB" };
    double fileSize = fileInfo.Length;
    int unitIndex = 0;
    while (fileSize >= 1024 && unitIndex < sizeUnits.Length - 1) {
      fileSize /= 1024;
      unitIndex++;
    }
    return $"{fileSize:F2} {sizeUnits[unitIndex]}";
  }
}

效果

  • 大文件以 MB 或 GB 顯示,小文件以 KB 顯示,確保用戶對(duì)文件大小的直觀感受更加準(zhǔn)確。

  • 用戶界面更加清晰整潔,避免了不必要的視覺負(fù)擔(dān)。

顯示更友好的視頻長(zhǎng)度

對(duì)于視頻文件的長(zhǎng)度,直接以秒或毫秒顯示并不友好。為了提供更直觀的體驗(yàn),我選擇了將視頻長(zhǎng)度轉(zhuǎn)換為格式化的時(shí)間顯示,如 HH:mm:ss,讓用戶能夠快速了解視頻的時(shí)長(zhǎng)。

例如,一個(gè)長(zhǎng) 5 分鐘 44 秒的視頻,系統(tǒng)會(huì)顯示為 00:05:44,而不是直接顯示秒數(shù)(如 344 秒)。這種顯示方式符合用戶日常的認(rèn)知習(xí)慣,讓用戶能更輕松地估計(jì)視頻內(nèi)容的時(shí)間跨度。

依然是使用擴(kuò)展方法來實(shí)現(xiàn)(我甚至還寫了英文版本)

public static class TimeSpanExtensions {
  public static string ToFriendlyString(this TimeSpan timeSpan, string locale = "zh-cn") {
    var parts = new List<string>();
    switch (locale) {
      case "zh-cn":
        if (timeSpan.Days > 0)
          parts.Add($"{timeSpan.Days}天");
        if (timeSpan.Hours > 0)
          parts.Add($"{timeSpan.Hours}小時(shí)");
        if (timeSpan.Minutes > 0)
          parts.Add($"{timeSpan.Minutes}分鐘");
        if (timeSpan.Seconds > 0)
          parts.Add($"{timeSpan.Seconds}秒");
        // 如果沒有天、小時(shí)、分鐘或秒的部分,顯示為 0 秒
        if (parts.Count == 0)
          return "0 秒";
        break;
      default:
        if (timeSpan.Days > 0)
          parts.Add($"{timeSpan.Days} day{(timeSpan.Days > 1 ? "s" : "")}");
        if (timeSpan.Hours > 0)
          parts.Add($"{timeSpan.Hours} hour{(timeSpan.Hours > 1 ? "s" : "")}");
        if (timeSpan.Minutes > 0)
          parts.Add($"{timeSpan.Minutes} minute{(timeSpan.Minutes > 1 ? "s" : "")}");
        if (timeSpan.Seconds > 0)
          parts.Add($"{timeSpan.Seconds} second{(timeSpan.Seconds > 1 ? "s" : "")}");
        // 如果沒有天、小時(shí)、分鐘或秒的部分,顯示為 0 秒
        if (parts.Count == 0)
          return "0 seconds";
        break;
    }
    return string.Join(", ", parts);
  }
}

不過如果要固定格式的話,可以直接使用更簡(jiǎn)短的代碼:

public static string FormatVideoDuration(TimeSpan duration){
  return string.Format(
    "{0:D2}:{1:D2}:{2:D2}",
    duration.Hours,
    duration.Minutes,
    duration.Seconds);
}

小結(jié)

細(xì)節(jié)決定體驗(yàn)。在 Clipify 的設(shè)計(jì)中,顯示更友好的文件大小和視頻長(zhǎng)度是提升用戶體驗(yàn)的關(guān)鍵步驟。通過將技術(shù)邏輯轉(zhuǎn)化為直觀的界面元素,用戶可以更加輕松地操作視頻文件,減少因信息不直觀帶來的困擾。這些小細(xì)節(jié)的優(yōu)化將有助于提升整個(gè)工具的易用性和用戶滿意度。

文章小結(jié)

相比之前的 QuickCutSharp,這個(gè)新工具在開發(fā)體驗(yàn)和界面設(shè)計(jì)上更加靈活,也更加適合我的需求。雖然起初嘗試了一些其他的開發(fā)方案,如 Avalonia 和 MAUI,但最終因?yàn)榄h(huán)境復(fù)雜或平臺(tái)不支持而放棄。

使用 Blazor 和 TailwindCSS 構(gòu)建界面,既保持了熟悉的 C# 開發(fā)生態(tài),又帶來了現(xiàn)代化的前端體驗(yàn),這讓整個(gè)項(xiàng)目的開發(fā)更加順暢。雖然 Clipify 目前只實(shí)現(xiàn)了部分功能,但我對(duì)其未來的發(fā)展充滿期待。項(xiàng)目已經(jīng)開源,希望能對(duì)有類似需求的開發(fā)者提供一些幫助。

轉(zhuǎn)自https://www.cnblogs.com/deali/p/18458357


該文章在 2025/1/17 10:20:04 編輯過
關(guān)鍵字查詢
相關(guān)文章
正在查詢...
點(diǎn)晴ERP是一款針對(duì)中小制造業(yè)的專業(yè)生產(chǎn)管理軟件系統(tǒng),系統(tǒng)成熟度和易用性得到了國(guó)內(nèi)大量中小企業(yè)的青睞。
點(diǎn)晴PMS碼頭管理系統(tǒng)主要針對(duì)港口碼頭集裝箱與散貨日常運(yùn)作、調(diào)度、堆場(chǎng)、車隊(duì)、財(cái)務(wù)費(fèi)用、相關(guān)報(bào)表等業(yè)務(wù)管理,結(jié)合碼頭的業(yè)務(wù)特點(diǎn),圍繞調(diào)度、堆場(chǎng)作業(yè)而開發(fā)的。集技術(shù)的先進(jìn)性、管理的有效性于一體,是物流碼頭及其他港口類企業(yè)的高效ERP管理信息系統(tǒng)。
點(diǎn)晴WMS倉(cāng)儲(chǔ)管理系統(tǒng)提供了貨物產(chǎn)品管理,銷售管理,采購(gòu)管理,倉(cāng)儲(chǔ)管理,倉(cāng)庫(kù)管理,保質(zhì)期管理,貨位管理,庫(kù)位管理,生產(chǎn)管理,WMS管理系統(tǒng),標(biāo)簽打印,條形碼,二維碼管理,批號(hào)管理軟件。
點(diǎn)晴免費(fèi)OA是一款軟件和通用服務(wù)都免費(fèi),不限功能、不限時(shí)間、不限用戶的免費(fèi)OA協(xié)同辦公管理系統(tǒng)。
Copyright 2010-2025 ClickSun All Rights Reserved