评论

桌面软件开发新体验!用 Blazor Hybrid 打造简洁高效的视频处理工具

前言

PS:昨天那篇文章忘记加原创了,今天再发一次

国庆假期各种活动比较多,直到上班才有时间来更新文章~

不过这两天我还是做了个小玩意(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# 开发人员的优势是不需要学习各种 Java 框架就可以开发交互式的 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 上面

然后开始写代码初始化

publicpartialclassFormMain: Form{

publicFormMain( ) {

InitializeComponent;

varservices = newServiceCollection;

services.AddLogging(c => {

c.AddDebug;

c.AddFilter( "Microsoft.AspNetCore.Components.WebView", LogLevel.Trace);

});

services.AddAntDesign;

services.AddMediatR(cfg => { cfg.RegisterServicesFromAssemblyContaining<FormMain>; });

services.AddWindowsFormsBlazorWebView;

# ifDEBUG

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>

< htmllang= "en">

< head>

< metacharset= "utf-8"/>

< metaname= "viewport"content= "width=device-width, initial-scale=1.0"/>

< title> Clipify </ title>

< basehref= "/"/>

< linkhref= "css/app.css"rel= "stylesheet"/>

< linkhref= "css/tailwind.min.css"rel= "stylesheet"/>

< linkhref= "lib/font-awesome/css/all.min.css"rel= "stylesheet">

< linkhref= "_content/AntDesign/css/ant-design-blazor.css"rel= "stylesheet"/>

< linkhref= "Clipify.Forms.styles.css"rel= "stylesheet"/>

</ head>

< body>

< divid= "app"> Loading... </ div>

< divid= "blazor-error-ui"data-nosnippet>

An unhandled error has occurred.

< ahref= ""class= "reload"> Reload </ a>

< aclass= "dismiss"> 🗙 </ a>

</ div>

< src= "_framework/blazor.webview.js"> </ >

< src= "lib/flowbite/flowbite.min.js"> </ >

< src= "_content/AntDesign/js/ant-design-blazor.js"> </ >

< >

window.initializeFlowbite = => {

initFlowbite;

}

</ >

</ 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 ,这样在路由跳转的时候比较方便。

namespaceClipify.Forms;

publicstaticclassRouterMap{

publicconststringIndex = "/";

publicconststringVideoSplit = "/video-split";

publicconststringExtractAudio = "/extract-audio";

}

导航栏

导航栏的完整代码省略了,有兴趣的同学之间在 Github 上看完整代码吧。

这里记录一个老生常谈的问题,如何高亮当前菜单?

有两种方式:

  • NavigationManager 获取当前路径

  • NavLink组件

在本文中我使用的是 NavLink 组件,类似这样:

当路径与菜单的 href 相同时,元素会自动加上 ActiveClass 里的 class,从而实现高亮当前菜单的效果。

< NavLinkhref= "@RouterMap.ExtractAudio"ActiveClass= "bg-gray-200">

< iclass= "fa-solid fa-music"> </ i>

< span> 提取音频 </ span>

</ NavLink>

因为篇幅关系省略了 TailwindCSS 的 class

使用 MediatR 实现内部通信

目前是把 MediatR 用在了对话框的数据交互上。

因为要处理视频,所以需要一个打开文件的对话框,和一个选择输出目录的对话框。

Blazor 组件是运行在浏览器里的,浏览器自然也能打开文件,不过打开后程序只能拿到文件的 stream ,而我需要拿到文件在电脑里的存储路径,用于调用 ffmpeg 命令进行处理。

这种情况下只能使用 WinForms 的对话框控件了,Blazor 组件与 WinForms 处在同个进程,这种情况下,使用 MediatR 这类进程内消息队列就很合适了。

MediatR 支持两种类型的消息,分别是

  • Request/responsemessages, dispatched to a single handler

  • Notificationmessages, dispatched to multiple handlers

一种是一对一,另一种是一对多。

我的用法是这样:

  • Blazor组件里请求打开对话框,使用 request/response 一对一模式

  • 对话框选择完通知 Blazor 组件,使用一对多的 Notification 模式

封装 Service

为了屏蔽细节和解耦,我封装了 DialogService,这样做的好处是可以进一步简化组件与 MediatR 之间的通信,确保所有与文件对话框相关的逻辑集中在一个地方,使代码更具可维护性和一致性。

publicclassDialogService{

privatereadonlyIMediator _mediator;

publiceventFunc< string, Task>? OnFileSelected;

publiceventFunc< string, Task>? OnDirSelected;

publicDialogService( IMediator mediator) {

_mediator = mediator;

}

publicasyncTask< string> OpenFileAsync( ) {

returnawait_mediator.Send( newOpenFileRequest);

}

publicasyncTask< string> OpenDirAsync( ) {

returnawait_mediator.Send( newOpenDirRequest);

}

publicvoidNotifyFileSelected( stringpath ) {

OnFileSelected?.Invoke(path);

}

publicvoidNotifyDirSelected( stringpath ) {

OnDirSelected?.Invoke(path);

}

}

其中有两个事件,分别是打开文件和选择目录。这样设计的好处有几点:

  • 集中管理:所有与文件对话框相关的逻辑都封装在 DialogService,包括 MediatR 的请求和处理。这样可以在一个地方轻松维护代码,提高可读性和可维护性。

  • 松耦合:Blazor 组件不需要知道 MediatR 的细节,只需与服务进行简单的交互,符合单一职责原则。MediatR 的调用逻辑被隐藏在服务中,不会污染其他部分的代码。

  • 便于测试:通过将 MediatR 的调用封装到服务中,你可以更容易地测试服务逻辑和 MediatR 的交互,而不需要在 Blazor 组件中进行复杂的测试。

以打开文件为例。

一对一的 Request

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

usingClipify.Forms.EventBus.Notification;

usingMediatR;

namespaceClipify.Forms.EventBus.Request;

publicclassOpenFileRequest: IRequest< string> { }

publicclassOpenFileHandler: IRequestHandler< OpenFileRequest, string> {

privatereadonlyIMediator _mediator;

privatereadonlyFormMain _formMain;

publicOpenFileHandler( FormMain formMain, IMediator mediator) {

_formMain = formMain;

_mediator = mediator;

}

publicTask< string> Handle( OpenFileRequest request, CancellationToken cancellationToken) {

varresult = _formMain.openFileDialog.ShowDialog;

if(result == DialogResult.OK) {

varpath = _formMain.openFileDialog.FileName;

_mediator.Publish( newFileSelectedNoti {

SelectedPath = path

}, cancellationToken);

returnTask.FromResult(path);

}

returnTask.FromResult( "");

}

}

收到 Request 之后,RequestHandler 里通过依赖注入拿到 MainForm 的实例,然后调用对话框拿到文件路径,再发送通知。

一对多的 Notification

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

PS:其实也可以使用 Request 的返回值来拿到文件路径,不过我还是”多此一举“使用了 Notification

usingClipify.Forms.Services;

usingMediatR;

namespaceClipify.Forms.EventBus.Notification;

publicclassFileSelectedNoti: INotification{

publicstringSelectedPath { get; set; }

}

publicclassFileSelectedHandler: INotificationHandler< FileSelectedNoti> {

privatereadonlyDialogService _dialogService;

publicFileSelectedHandler( DialogService dialogService) {

_dialogService = dialogService;

}

publicTask Handle( FileSelectedNoti notification, CancellationToken cancellationToken) {

_dialogService.NotifyFileSelected(notification.SelectedPath);

returnTask.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 = newProcess;

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

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 ,如果有需要可以自己写正则解析一下。

// 使用正则表达式提取各项信息

stringsizePattern = @"size=\s*(\d+)(\w+)";

stringtimePattern = @"time=(\d{2}:\d{2}:\d{2}\.\d{2})";

stringbitratePattern = @"bitrate=\s*(\d+\.\d+|\d+)(\w+)";

stringspeedPattern = @"speed=\s*(\d+\.\d+|\d+)x";

缩略图

在 Clipify 中,视频缩略图是帮助用户快速预览视频的重要功能。

在本项目的开发中,我探索了几种不同的缩略图策略:

  • 视频文件的 MD5 - 如果视频文件较大且频繁进行哈希计算,可能会带来一定的性能开销

  • 文件路径 MD5 - 如果文件路径改变了(例如文件移动或重命名),尽管文件内容未变,MD5 仍然会不同,导致生成新的缩略图。这可能会造成不必要的重复生成缩略图。

  • 结合文件的其他属性(如文件名、修改时间等)进行 MD5 计算 - 这种方式可以兼顾路径变化和文件唯一性的平衡,进一步减少重复缩略图的生成

为了避免重复生成缩略图,我采用了基于 MD5 哈希的策略为每个视频生成唯一的缩略图文件名。这样可以确保同一视频即使在不同时间被访问,仍然可以使用缓存的缩略图,提升性能。

这部分代码集成在 VideoService 里面。

生成缩略图的代码

使用了 FFmpeg.NET 提供的生成缩略图功能(其实就是调用ffmpeg对视频进行截图),根据规则生成文件名,之后把缩略图文件保存到 wwwroot/temp/thumbnails 目录里面。

publicasyncTask< string> GenerateThumbnailAsync( stringvideoPath, CancellationToken? cancellationToken = null) {

varinputFile = newInputFile(videoPath);

vartempThumbnailDir = Path.Combine(_environment.WebRootPath, "temp", "thumbnails");

if(!Directory.Exists(tempThumbnailDir)) {

Directory.CreateDirectory(tempThumbnailDir);

}

varfilename = $" {GetFileMetadataMd5(videoPath)}.jpeg" ;

varoutputPath = Path.Combine(tempThumbnailDir, filename);

varoutputFile = newOutputFile(outputPath);

varopt = newConversionOptions {

HideBanner = true,

HWAccelOutputFormatCopy = true,

MapMetadata = true,

};

if(!File.Exists(outputPath)) {

awaitFFmpeg.GetThumbnailAsync(inputFile, outputFile, cancellationToken ?? CancellationToken.None);

}

return$"temp/thumbnails/ {filename}" ;

}

视频文件的 MD5 哈希

最直接的方式是对整个视频文件进行 MD5 哈希运算,将其生成的哈希值作为缩略图的文件名。然而,如果视频文件较大,频繁进行哈希计算可能带来显著的性能开销。

publicstaticstringGetFileMd5( stringfilePath ) {

usingvarmd5 = MD5.Create;

usingvarstream = File.OpenRead(filePath);

varhash = md5.ComputeHash(stream);

returnBitConverter.ToString(hash).Replace( "-", "").ToLowerInvariant;

}

优点:文件内容唯一性强,可以确保不同内容的视频不会生成相同的缩略图。

缺点:对于大型文件,MD5 计算耗时较长,影响性能。实测几个G的视频要花好几秒的时间。

文件路径的 MD5 哈希

为了提高性能,也可以仅对文件路径进行 MD5 计算。这种方式大大减少了计算量,适用于那些文件内容不变但需要频繁生成缩略图的场景。然而,当文件被移动或重命名时,尽管视频内容没有变化,生成的 MD5 值会不同,可能导致不必要的重复缩略图生成。

stringfilePathHash;

using( varmd5 = MD5.Create) {

varpathBytes = Encoding.UTF8.GetBytes(videoFilePath);

varhash = md5.ComputeHash(pathBytes);

filePathHash = BitConverter.ToString(hash).Replace( "-", "").ToLower;

}

优点:高效,MD5 计算速度极快,适合频繁使用。

缺点:文件路径变动时,即使文件内容不变,仍会生成新缩略图,可能导致冗余的缩略图生成。

结合文件属性进行 MD5 计算

为了在路径变化和文件内容唯一性之间找到平衡,Clipify 还可以结合文件的其他属性,如文件名、修改时间等进行 MD5 计算。这样即使文件路径发生变化,只要文件内容和其属性不变,MD5 也不会变化,避免不必要的重复生成。

publicstaticstringGetFileMetadataMd5( stringfilePath ) {

varfileName = Path.GetFileName(filePath);

varfileInfo = newFileInfo(filePath);

varmetaData = fileName + fileInfo.LastWriteTimeUtc.ToString;

usingvarmd5 = MD5.Create;

varmetaBytes = System.Text.Encoding.UTF8.GetBytes(metaData);

varhash = md5.ComputeHash(metaBytes);

returnBitConverter.ToString(hash).Replace( "-", "").ToLowerInvariant;

}

优点

  • 兼顾了文件内容的唯一性和文件路径的变化。

  • 减少了重复缩略图生成的情况。

缺点:需要结合多个文件属性,计算稍微复杂,但仍能有效提升性能。

小结

在 Clipify 中,选择如何生成视频缩略图的哈希值需要在性能和唯一性之间做平衡。

对于较大的视频文件,直接对文件进行 MD5 计算虽然保证了内容的唯一性,但对性能影响较大。

而通过结合文件路径和文件属性来生成哈希值,可以减少性能消耗并避免冗余的缩略图生成。

在后续的版本中,可以考虑小文件使用文件内容生成MD5,大文件继续用综合路径和属性的方式来生成MD5。

显示视频导出进度

目前是用 FFmpeg.Net 的 OnProgress 事件,保留小数点后两位

privateasyncvoidOnProgress( object? sender, ConversionProgressEventArgs e ) {

Status.Status = StatusEnum.Running;

Status.Progress = Math.Round(e.ProcessedDuration.TotalSeconds / MetaData.Duration.TotalSeconds * 100, 2);

awaitInvokeAsync(StateHasChanged);

}

如果要更详细的显示处理时的其他信息,可以参考前面的与FFmpeg交互部分。

细节

在 Clipify 的设计过程中,我非常注重用户体验中的细节,尤其是如何让用户更直观、轻松地理解视频文件的属性。因此,除了基本的视频编辑功能,我还在界面上优化了文件大小和视频长度的显示方式。

本文选择了这两点来介绍:

  • 显示更友好的文件大小

  • 显示更友好的视频长度

显示更友好的文件大小

视频文件通常较大,直接显示以字节(bytes)为单位的大小可能不够直观。为了提升用户体验,我选择了将文件大小转换为更常见的单位,如 KB、MB 或 GB,并使用四舍五入让显示更简洁。

例如,如果视频文件大小为 3,304,582 字节,则会显示为 3.30 MB。这样一来,用户不需要进行单位换算,直接可以看到文件的大致大小。

这里我写了一个扩展方法来实现。

publicstaticclassFileInfoExtensions{

publicstaticstringGetFriendlySize( thisFileInfo fileInfo ) {

string[] sizeUnits = { "Bytes", "KB", "MB", "GB", "TB"};

doublefileSize = fileInfo.Length;

intunitIndex = 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 秒)。这种显示方式符合用户日常的认知习惯,让用户能更轻松地估计视频内容的时间跨度。

依然是使用扩展方法来实现(我甚至还写了英文版本)

publicstaticclassTimeSpanExtensions{

publicstaticstringToFriendlyString( thisTimeSpan timeSpan, stringlocale = "zh-cn") {

varparts = newList< 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;

}

returnstring.Join( ", ", parts);

}

}

不过如果要固定格式的话,可以直接使用更简短的代码:

publicstaticstringFormatVideoDuration( TimeSpan duration)

{

returnstring.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返回搜狐,查看更多

责任编辑:

平台声明:该文观点仅代表作者本人,搜狐号系信息发布平台,搜狐仅提供信息存储空间服务。
阅读 ()