PC軟體開發新體驗!用 Blazor Hybrid 打造簡潔高效的影片處理工具

程序设计实验室發表於2024-10-10

前言

國慶假期各種活動比較多,直到上班才有時間來更新文章~

不過這兩天我還是做了個小玩意(Clipify),起因是想給之前開發來自己用的簡單影片剪輯工具 QuickCutSharp 加個功能,不過這個軟體是基於 WinForms 開發的,做介面得拖拉控制元件,感覺繁瑣又不靈活,於是索性重新做一個。

原有程式碼是C#,於是我就繼續在這個生態裡尋找開發方案,Avalonia、MAUI等都是不錯的選擇,前者我之前用過,做了個簡單的圖片管理工具,後者聽說是微軟新推出的跨平臺開發方案,我這次也試了一下,不過單純處理環境就比較複雜了,直接勸退。

接下來我就把目光瞄準了類似 Electron 這類套殼開發,既然要用前端技術開發軟體介面,那麼 C# 生態的 Blazor 就可以拿出來了,我之前也用 Blazor 開發過幾個專案,感覺使用 Blazor 搭配 TailwindCSS 應該可以有不錯的開發體驗。

說幹就幹,我選擇了 Blazor Hybrid 這個方向,然後宿主容器依然選擇 WinForms,原因是暫時沒有跨平臺的需求,而且 Blazor Hybrid 目前也沒有比較好的跨平臺方案,雖然有 MAUI 但太重而且也不支援 Linux…


專案已經開源,Github: https://github.com/Deali-Axy/clipify

一些截圖

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

軟體主頁

提取音訊介面

匯出影片介面

PS:目前只實現了部分功能

主要技術

正如前言說到的,使用了 Blazor Hybrid 來開發,那麼介面就是 Blazor 實現的,然後執行在一個 Winforms 軟體的 BlazorWebView 中。

影片相關的功能是呼叫了 ffmpeg (實際上在沒有這個軟體之前,我都是手動輸入命令操作的…)

  • Microsoft.AspNetCore.Components.WebView.WindowsForms - 微軟官方的 Blazor Hybrid 方案,可以依託 WinForms 執行 Blazor
  • MediatR - C#版的EventBus,用於實現瀏覽器和WinForms的通訊
  • xFFmpeg.NET - 用於簡化 ffmpeg 的呼叫(實際上這個庫已經停更兩三年了,很多功能只能自己去實現,我甚至打算fork一個來適配新版ffmpeg)
  • Microsoft.Extensions.Logging - 日誌元件,沒啥好說的,AspNetCore專案裡的常客
  • AntDesign - 一些元件不想自己封裝(如modal和message)就用這個

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

關於 Blazor Hybrid

Electron技術大家都很熟悉了,現在連QQ都用Electron重構了,在開發了這個專案之後,我也能理解這種做法,用前端技術來寫介面真的爽,只要稍微犧牲一下效能,就可以獲得不錯的效果,而且現在電腦的效能都已經足夠了,正好給web技術上桌面提供了條件。

而 Blazor 對於 C# 開發人員的優勢是不需要學習各種 JavaScript 框架就可以開發互動式的 web 應用;雖然我做過不少前端專案,React也用得比較熟了,不過 Blazor Hybrid 還有一個優勢是可以直接使用 C# 呼叫系統功能,Blazor Hybrid 一方面是執行在瀏覽器中,一方面又是直接在作業系統層面執行,C# 程式碼可以不受瀏覽器沙箱的限制,直接訪問系統檔案、裝置等(雖然本專案中還是用到了Blazor與WinForms通訊,不過那不是 C# 的功能限制,而是必須用到 WinForms 的功能)。

建立 Blazor Hybrid 專案

建立一個基於 WinForms 的 Blazor Hybrid 專案很簡單,首先是建立 .NetCore(.Net8) 的 WinForms 專案,然後新增 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");
  }
}

關鍵的就在於最下面的三行程式碼,設定主頁、把服務容器繫結的 Blazor 控制元件上,設定根元件。

然後其他的就和普通的 Blazor 專案一樣。

搭建專案基礎架構

本文限於篇幅,只能簡單介紹一下。

想要進一步瞭解的同學可以看官網的指引文件和例項專案。

不過微軟官閘道器於這方面的文件也不是很詳細,只是淺嘗輒止,很多內容要靠自己摸索。

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

這個是根元件

<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");
        }
    }
}

基礎功能到這裡就搞定了

我習慣在專案里加一個 RouterMap ,這樣在路由跳轉的時候比較方便。

namespace Clipify.Forms;

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

導航欄

導航欄的完整程式碼省略了,有興趣的同學之間在 Github 上看完整程式碼吧。

這裡記錄一個老生常談的問題,如何高亮當前選單?

有兩種方式:

  • NavigationManager 獲取當前路徑
  • NavLink元件

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

當路徑與選單的 href 相同時,元素會自動加上 ActiveClass 裡的 class,從而實現高亮當前選單的效果。

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

因為篇幅關係省略了 TailwindCSS 的 class

使用 MediatR 實現內部通訊

目前是把 MediatR 用在了對話方塊的資料互動上。

因為要處理影片,所以需要一個開啟檔案的對話方塊,和一個選擇輸出目錄的對話方塊。

Blazor 元件是執行在瀏覽器裡的,瀏覽器自然也能開啟檔案,不過開啟後程式只能拿到檔案的 stream ,而我需要拿到檔案在電腦裡的儲存路徑,用於呼叫 ffmpeg 命令進行處理。

這種情況下只能使用 WinForms 的對話方塊控制元件了,Blazor 元件與 WinForms 處在同個程序,這種情況下,使用 MediatR 這類程序內訊息佇列就很合適了。

MediatR 支援兩種型別的訊息,分別是

  • Request/response messages, dispatched to a single handler
  • Notification messages, dispatched to multiple handlers

一種是一對一,另一種是一對多。

我的用法是這樣:

  • Blazor元件裡請求開啟對話方塊,使用 request/response 一對一模式
  • 對話方塊選擇完通知 Blazor 元件,使用一對多的 Notification 模式

封裝 Service

為了遮蔽細節和解耦,我封裝了 DialogService,這樣做的好處是可以進一步簡化元件與 MediatR 之間的通訊,確保所有與檔案對話方塊相關的邏輯集中在一個地方,使程式碼更具可維護性和一致性。

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);
  }
}

其中有兩個事件,分別是開啟檔案和選擇目錄。這樣設計的好處有幾點:

  • 集中管理:所有與檔案對話方塊相關的邏輯都封裝在 DialogService,包括 MediatR 的請求和處理。這樣可以在一個地方輕鬆維護程式碼,提高可讀性和可維護性。
  • 松耦合:Blazor 元件不需要知道 MediatR 的細節,只需與服務進行簡單的互動,符合單一職責原則。MediatR 的呼叫邏輯被隱藏在服務中,不會汙染其他部分的程式碼。
  • 便於測試:透過將 MediatR 的呼叫封裝到服務中,你可以更容易地測試服務邏輯和 MediatR 的互動,而不需要在 Blazor 元件中進行復雜的測試。

以開啟檔案為例。

一對一的 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 的例項,然後呼叫對話方塊拿到檔案路徑,再傳送通知。

一對多的 Notification

程式碼 Clipify.Forms/EventBus/Notification/FileSelectedNoti.cs

PS:其實也可以使用 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;
  }
}

這個程式碼很簡單,就是呼叫了 DialogService 的事件處理器。

與 ffmpeg 互動

在開發 Clipify 工具時,影片處理的核心依賴於 ffmpeg,這是一款強大的多媒體處理工具。為了實現影片剪輯、音訊提取等功能,我探索了多種與 ffmpeg 互動的方式,包括使用現有的 C# 庫以及直接透過系統程序呼叫 ffmpeg。

經過研究,可以用這幾種方式來實現。

  • FFmpeg.NET - 之前 QuickCutSharp 就是用這個實現的
  • FFMpegCore - GitHub上的star比較多
  • 直接 Process 呼叫

前兩種都是用第三方庫,我就不太多介紹了,有興趣的同學直接看官方文件就行。另外提一點,C# 這邊的生態還是差了點,就算是1k多star的FFMpegCore也沒啥文件,只有一個專案的 README;前面那個 FFmpeg.NET 就更不用說了,已經停更了,而且文件有些程式碼和實際使用還對不上。

不過這些都是對於 ffmpeg 的呼叫,自己實現也是沒問題的。下面是簡單的例子:

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)) {
    // 處理標準輸出中的進度資訊
    Console.WriteLine(e.Data);
    // 可以在這裡解析 e.Data 以提取進度
  }
};

ffmpegProcess.Start();
ffmpegProcess.BeginOutputReadLine();
ffmpegProcess.WaitForExit();

引數說明:

  • -progress pipe:1:表示將進度資訊輸出到標準輸出(stdout,即控制檯)。FFmpeg 將輸出一系列結構化的鍵值對,表示當前進度的狀態。
  • pipe:1:是 FFmpeg 中表示標準輸出流的方式,pipe:0 表示標準輸入(stdin),pipe:1 表示標準輸出(stdout),pipe:2 表示標準錯誤(stderr)。

在 ffmpeg 的引數里加上 -progress pipe:1 ,FFmpeg 會輸出類似於以下內容的進度資訊:

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

這樣就可以簡單的獲取更詳細的影片處理進度資訊。

不過 FFmpeg.NET 的 onData 事件是無法獲取這段資訊的,一般會獲取到類似這樣的輸出:

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

就算新增了引數,也只能獲取這一行的資訊,所以要詳細資訊的話只能自己呼叫 Process 來處理。

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

// 使用正規表示式提取各項資訊
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 中,影片縮圖是幫助使用者快速預覽影片的重要功能。

在本專案的開發中,我探索了幾種不同的縮圖策略:

  • 影片檔案的 MD5 - 如果影片檔案較大且頻繁進行雜湊計算,可能會帶來一定的效能開銷
  • 檔案路徑 MD5 - 如果檔案路徑改變了(例如檔案移動或重新命名),儘管檔案內容未變,MD5 仍然會不同,導致生成新的縮圖。這可能會造成不必要的重複生成縮圖。
  • 結合檔案的其他屬性(如檔名、修改時間等)進行 MD5 計算 - 這種方式可以兼顧路徑變化和檔案唯一性的平衡,進一步減少重複縮圖的生成

為了避免重複生成縮圖,我採用了基於 MD5 雜湊的策略為每個影片生成唯一的縮圖檔名。這樣可以確保同一影片即使在不同時間被訪問,仍然可以使用快取的縮圖,提升效能。

這部分程式碼整合在 VideoService 裡面。

生成縮圖的程式碼

使用了 FFmpeg.NET 提供的生成縮圖功能(其實就是呼叫ffmpeg對影片進行截圖),根據規則生成檔名,之後把縮圖檔案儲存到 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 雜湊

最直接的方式是對整個影片檔案進行 MD5 雜湊運算,將其生成的雜湊值作為縮圖的檔名。然而,如果影片檔案較大,頻繁進行雜湊計算可能帶來顯著的效能開銷。

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();
}

優點:檔案內容唯一性強,可以確保不同內容的影片不會生成相同的縮圖。

缺點:對於大型檔案,MD5 計算耗時較長,影響效能。實測幾個G的影片要花好幾秒的時間。

檔案路徑的 MD5 雜湊

為了提高效能,也可以僅對檔案路徑進行 MD5 計算。這種方式大大減少了計算量,適用於那些檔案內容不變但需要頻繁生成縮圖的場景。然而,當檔案被移動或重新命名時,儘管影片內容沒有變化,生成的 MD5 值會不同,可能導致不必要的重複縮圖生成。

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

優點:高效,MD5 計算速度極快,適合頻繁使用。

缺點:檔案路徑變動時,即使檔案內容不變,仍會生成新縮圖,可能導致冗餘的縮圖生成。

結合檔案屬性進行 MD5 計算

為了在路徑變化和檔案內容唯一性之間找到平衡,Clipify 還可以結合檔案的其他屬性,如檔名、修改時間等進行 MD5 計算。這樣即使檔案路徑發生變化,只要檔案內容和其屬性不變,MD5 也不會變化,避免不必要的重複生成。

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();
}

優點

  • 兼顧了檔案內容的唯一性和檔案路徑的變化。
  • 減少了重複縮圖生成的情況。

缺點:需要結合多個檔案屬性,計算稍微複雜,但仍能有效提升效能。

小結

在 Clipify 中,選擇如何生成影片縮圖的雜湊值需要在效能和唯一性之間做平衡。

對於較大的影片檔案,直接對檔案進行 MD5 計算雖然保證了內容的唯一性,但對效能影響較大。

而透過結合檔案路徑和檔案屬性來生成雜湊值,可以減少效能消耗並避免冗餘的縮圖生成。

在後續的版本中,可以考慮小檔案使用檔案內容生成MD5,大檔案繼續用綜合路徑和屬性的方式來生成MD5。

顯示影片匯出進度

目前是用 FFmpeg.Net 的 OnProgress 事件,保留小數點後兩位

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);
}

如果要更詳細的顯示處理時的其他資訊,可以參考前面的與FFmpeg互動部分。

細節

在 Clipify 的設計過程中,我非常注重使用者體驗中的細節,尤其是如何讓使用者更直觀、輕鬆地理解影片檔案的屬性。因此,除了基本的影片編輯功能,我還在介面上最佳化了檔案大小和影片長度的顯示方式。

本文選擇了這兩點來介紹:

  • 顯示更友好的檔案大小
  • 顯示更友好的影片長度

顯示更友好的檔案大小

影片檔案通常較大,直接顯示以位元組(bytes)為單位的大小可能不夠直觀。為了提升使用者體驗,我選擇了將檔案大小轉換為更常見的單位,如 KB、MB 或 GB,並使用四捨五入讓顯示更簡潔。

例如,如果影片檔案大小為 3,304,582 位元組,則會顯示為 3.30 MB。這樣一來,使用者不需要進行單位換算,直接可以看到檔案的大致大小。

這裡我寫了一個擴充套件方法來實現。

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 顯示,確保使用者對檔案大小的直觀感受更加準確。
  • 使用者介面更加清晰整潔,避免了不必要的視覺負擔。

顯示更友好的影片長度

對於影片檔案的長度,直接以秒或毫秒顯示並不友好。為了提供更直觀的體驗,我選擇了將影片長度轉換為格式化的時間顯示,如 HH:mm:ss,讓使用者能夠快速瞭解影片的時長。

例如,一個長 5 分鐘 44 秒的影片,系統會顯示為 00:05:44,而不是直接顯示秒數(如 344 秒)。這種顯示方式符合使用者日常的認知習慣,讓使用者能更輕鬆地估計影片內容的時間跨度。

依然是使用擴充套件方法來實現(我甚至還寫了英文版本)

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}小時");

        if (timeSpan.Minutes > 0)
          parts.Add($"{timeSpan.Minutes}分鐘");

        if (timeSpan.Seconds > 0)
          parts.Add($"{timeSpan.Seconds}秒");

        // 如果沒有天、小時、分鐘或秒的部分,顯示為 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" : "")}");

        // 如果沒有天、小時、分鐘或秒的部分,顯示為 0 秒
        if (parts.Count == 0)
          return "0 seconds";
        break;
    }

    return string.Join(", ", parts);
  }
}

不過如果要固定格式的話,可以直接使用更簡短的程式碼:

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

小結

細節決定體驗。在 Clipify 的設計中,顯示更友好的檔案大小和影片長度是提升使用者體驗的關鍵步驟。透過將技術邏輯轉化為直觀的介面元素,使用者可以更加輕鬆地操作影片檔案,減少因資訊不直觀帶來的困擾。這些小細節的最佳化將有助於提升整個工具的易用性和使用者滿意度。

文章小結

相比之前的 QuickCutSharp,這個新工具在開發體驗和介面設計上更加靈活,也更加適合我的需求。雖然起初嘗試了一些其他的開發方案,如 Avalonia 和 MAUI,但最終因為環境複雜或平臺不支援而放棄。

使用 Blazor 和 TailwindCSS 構建介面,既保持了熟悉的 C# 開發生態,又帶來了現代化的前端體驗,這讓整個專案的開發更加順暢。雖然 Clipify 目前只實現了部分功能,但我對其未來的發展充滿期待。專案已經開源,希望能對有類似需求的開發者提供一些幫助。

參考資料

  • https://learn.microsoft.com/en-us/aspnet/core/blazor/hybrid/tutorials/windows-forms?view=aspnetcore-8.0
  • https://github.com/jbogard/MediatR/wiki
  • https://github.com/cmxl/FFmpeg.NET
  • https://github.com/dotnet/maui/tree/main/src/BlazorWebView/samples/BlazorWinFormsApp
  • https://github.com/rosenbjerg/FFMpegCore
  • https://antblazor.com/zh-CN/components/overview

相關文章