preamble
There are more activities during the National Day holiday, so I didn't have time to update the article until I got to work~.
However, in the past two days I still made a small gadget (Clipify), the reason is that I want to add a function to the previous development to use their own simple video editing tool QuickCutSharp, but this software is based on the development of WinForms, to do the interface has to drag and drop the control, the feeling is cumbersome and inflexible, so just to make a new one.
The original code is C#, so I continue to look for development solutions in this ecosystem, Avalonia, MAUI, etc. are good choices, the former I have used before, made a simple image management tools, the latter I heard is Microsoft's new cross-platform development solutions, I also tried this time, but simply dealing with the environment is more complex, directly persuaded to retreat.
Next, I set my sights on Electron-like shell development. Since I want to use front-end technology to develop software interfaces, Blazor, a C# ecosystem, can be used. I've used Blazor to develop a few projects before, and I feel that using Blazor with TailwindCSS should provide a good development experience.
I chose the direction of Blazor Hybrid, and then I still chose WinForms for the host container, because there is no need for cross-platform at the moment, and there is no better cross-platform solution for Blazor Hybrid, although there is MAUI, but it's too heavy and it doesn't support Linux....
The project is open source, Github./Deali-Axy/clipify
Some screenshots
The old rules in front of the first put some screenshots, the function of the software directly look at the picture is clear.
Software homepage
Extract Audio Interface
Export Video Interface
PS: Only part of the functionality has been implemented so far
Key technologies
As mentioned in the introduction, Blazor Hybrid was used for development, so the interface was implemented by Blazor and then ran in a Winforms software BlazorWebView.
The video related functions are calling ffmpeg (actually before I had this software, I was typing the commands manually...)
- - Microsoft's official Blazor Hybrid program, which runs Blazor off of WinForms.
- MediatR - C# version of EventBus for browser and WinForms communication
- - For simplifying ffmpeg calls (in fact, this library has been discontinued for two or three years, and many features can only be implemented on their own, I'm even going to fork one to adapt to the new version of ffmpeg)
- - Logging component, not much to say, AspNetCore project regulars
- AntDesign - Use this for components that you don't want to encapsulate yourself (e.g., modal and message).
The front-end is still pnpm, gulp, tailwindcss, flowbite, fortawesome, and so on.
About Blazor Hybrid
Electron technology we are all very familiar with, and now even QQ are using Electron refactoring, after the development of this project, I can understand this approach, with front-end technology to write the interface is really cool, as long as a slight sacrifice in performance, you can get a good result, and now the performance of the computer are enough, just to the web technology on the desktop to provide the conditions.
The advantage of Blazor for C# developers is that they don't need to learn various JavaScript frameworks to develop interactive web applications; although I've done a lot of front-end projects, and I'm quite familiar with React, Blazor Hybrid also has the advantage that you can use C# to call the system functions directly, Blazor Hybrid runs in the browser, and runs directly at the operating system level, C# code can access system files directly without the limitations of the browser sandbox (although this project still uses Blazor with WinForms). Blazor Hybrid on the one hand is running in the browser, on the other hand is running directly in the operating system level, C# code can not be restricted by the browser sandbox, direct access to system files, devices and so on (although the project still uses Blazor and WinForms communication, but that is not a limitation of the functionality of the C#, but must be used to WinForms functionality).
Creating a Blazor Hybrid Project
Creating a WinForms-based Blazor Hybrid project is as simple as creating a WinForms project for .NetCore (.Net8) and then adding the dependencies
Then add the BlazorWebView component on top of the Form
Then start writing code to initialize
public partial class FormMain : Form {
public FormMain() {
InitializeComponent();
var services = new ServiceCollection();
(c => {
();
("", );
});
();
(cfg => { <FormMain>(); });
();
#if DEBUG
();
#endif
(this);
<IHostingEnvironment, HostingEnvironment>();
<DialogService>();
<VideoService>();
= "wwwroot\\";
= ();
<App>("#app");
}
}
The key lies in the bottom three lines of code, setting up the home page, binding the service container to the Blazor control, and setting up the root component.
Then the rest is just like the regular Blazor program.
Build project infrastructure
This article is limited to space and can only be brief.
If you want to know more about it, you can read the guidelines and example projects on the official website.
However, the documentation on Microsoft's official website on this subject is not very detailed, just shallow, a lot of content to rely on their own to figure out.
Added various css and js references as needed
<!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/" rel="stylesheet"/>
<link href="css/" rel="stylesheet"/>
<link href="lib/font-awesome/css/" rel="stylesheet">
<link href="_content/AntDesign/css/" rel="stylesheet" />
<link href="" rel="stylesheet"/>
</head>
<body>
<div >Loading...</div>
<div data-nosnippet>
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/"></script>
<script src="lib/flowbite/"></script>
<script src="_content/AntDesign/js/"></script>
<script>
= () => {
initFlowbite();
}
</script>
</body>
</html>
This is the root component
<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 />
Layout components.
@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:///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 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 ("");
#endif
if (isFirstRender) {
await ("");
}
}
}
The basics are done here.
I'm used to adding a RouterMap to my projects so that it's easier when it comes to route jumping.
namespace ;
public static class RouterMap {
public const string Index = "/";
public const string VideoSplit = "/video-split";
public const string ExtractAudio = "/extract-audio";
}
navigation bar
The full code for the navigation bar is omitted, see the full code on Github between interested students.
Here's a record of the age-old question, how do I highlight the current menu?
There are two ways:
- NavigationManager Get current path
- NavLink Components
In this article I am using the NavLink component, something like this:
When the path is the same as the href of the menu, the element will automatically add the class from ActiveClass to highlight the current menu.
<NavLink href="@" ActiveClass="bg-gray-200">
<i class="fa-solid fa-music"></i>
<span> Extract audio</span>
</NavLink>
The class of TailwindCSS is omitted for space.
Internal communication with MediatR
MediatR is currently used for data interaction in dialog boxes.
Because you are working with video, you need a dialog box to open the file, and a dialog box to select the output directory.
The Blazor component runs in the browser, which naturally opens the file, but after opening the file the program can only get the stream of the file, and I need to get the path to where the file is stored on my computer, which I can use to call the ffmpeg command to process it.
In this case you can only use the WinForms dialog control, the Blazor component is in the same process as WinForms, in which case an in-process message queue such as MediatR would be appropriate.
MediatR supports two types of messages, which are
- Request/response messages, dispatched to a single handler
- Notification messages, dispatched to multiple handlers
One is one-to-one and the other is one-to-many.
My usage is this:
- Request to open dialog in Blazor component, using request/response one-to-one model
- Notify the Blazor component after the dialog box has been selected, using the one-to-many Notification pattern
Package Service
To shield the details and decoupling, I've encapsulated theDialogService
This has the advantage of further simplifying the communication between the component and MediatR, ensuring that all the logic related to the file dialog is in one place, making the code more maintainable and consistent.
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);
}
}
Two of these events are Open File and Select Directory. There are several advantages to this design:
-
centralized management: All logic related to the file dialog is encapsulated in the
DialogService
This includes MediatR requests and processing. This makes it easy to maintain the code in one place, improving readability and maintainability. - loose coupling: The Blazor component does not need to know the details of MediatR and simply interacts with the service, conforming to the single-responsibility principle. the call logic for MediatR is hidden in the service and does not contaminate other parts of the code.
- Easy to test: By encapsulating MediatR calls into a service, you can more easily test the interaction between the service logic and MediatR without the need for complex testing in Blazor components.
Take the example of opening a file.
One-on-One Request
coding/EventBus/Request/
using ;
using MediatR;
namespace ;
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.();
if (result == ) {
var path = _formMain.;
_mediator.Publish(new FileSelectedNoti {
SelectedPath = path
}, cancellationToken);
return (path);
}
return ("");
}
}
After receiving the Request, RequestHandler will get the instance of MainForm through dependency injection, then call the dialog to get the file path and send the notification.
One-to-many Notification
coding/EventBus/Notification/
PS: You can actually use the return value of the Request to get the path to the file, but I've gone the extra mile and used Notification.
using ;
using MediatR;
namespace ;
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();
return ;
}
}
This code is simple, it calls the DialogService's event handler.
Interacting with ffmpeg
In developing the Clipify tool, the core of video processing relies on ffmpeg, a powerful multimedia processing tool. I explored several ways to interact with ffmpeg for video editing, audio extraction, etc., including using existing C# libraries and calling ffmpeg directly from a system process.
After some research, this can be accomplished in these ways.
- - That's what QuickCutSharp did before.
- FFMpegCore - more star on GitHub
- Direct Process Calls
The first two are using third-party libraries, I'm not too much to introduce, interested students directly look at the official documents on the line. Another point, C# this side of the ecological or poor point, even more than 1k star FFMpegCore also has no documentation, only a project README; the front of the needless to say, has been discontinued, and some of the code and the actual use of the document can not be matched.
But these are all calls for ffmpeg, and it's no problem to implement them yourself. Here is a simple example:
Process ffmpegProcess = new Process();
= "ffmpeg".
= "-i input.mp4 -progress pipe:1 -f mp4 output.mp4";
= true; = false;
= false.
= true; = false;
+= (sender, e) => {
if (! ()) {
// Process the progress information in the standard output
(); // Parsing can be done here to extract the progress information from the standard output.
// Can be parsed here to extract progress
}
}
(); // You can parse it here to extract the progress } }
(); (); (); (); (); (); (); (); ()
();
Parameter Description:
-
-progress pipe:1
: Indicates that progress information is output to standard output (stdout
FFmpeg will output a series of structured key-value pairs indicating the status of the current progress. -
pipe:1
: is the way to represent the standard output stream in FFmpeg.pipe:0
Indicates the standard input (stdin
),pipe:1
Indicates a standard output (stdout
),pipe:2
Indicates a standard error (stderr
)。
Add the following to the ffmpeg parameters-progress pipe:1
FFmpeg will output progress information similar to the following:
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
This makes it simple to get more detailed information about the progress of the video processing.
neverthelessonData
The event is unable to fetch this information, and will generally fetch output similar to this:
size= 16522KiB time=00:21:19.01 bitrate= 105.8kbits/s speed=68.9x
Even if you add a parameter, you can only get the information of this line, so if you want detailed information, you can only call Process to process it by yourself.
and there is a problem with the OnProgress event, which only fetches theProcessedDuration
information, nothing else, I do not know if the version is too old, does not match the new version of ffmpeg, if necessary you can write your own regular parsing.
// Use regular expressions to extract each piece of information
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";
thumbnail
In Clipify, video thumbnails are an important feature to help users preview videos quickly.
In the development of this project, I explored several different thumbnail strategies:
- MD5 of video files - if the video file is large and hash calculations are performed frequently, there may be a performance overhead
- File Path MD5 - If the file path is changed (e.g. file moved or renamed), the MD5 will be different even though the file content remains the same, causing new thumbnails to be generated. This may result in unnecessary duplication of thumbnail generation.
- MD5 calculation in combination with other attributes of the file (e.g. filename, modification time, etc.) - this approach balances path changes with file uniqueness and further reduces the generation of duplicate thumbnails
To avoid duplicate thumbnail generation, I used an MD5 hash-based strategy to generate unique thumbnail filenames for each video. This ensures that even if the same video is accessed at different times, the cached thumbnail can still be used, improving performance.
This part of the code is integrated in theVideoService
Inside.
Code for generating thumbnails
Using the thumbnail generation function (which is actually a call to ffmpeg to take a screenshot of the video), generate the filename according to the rules, and then save the thumbnail file to thewwwroot/temp/thumbnails
Inside the catalog.
public async Task<string> GenerateThumbnailAsync(string videoPath, CancellationToken? cancellationToken = null) {
var inputFile = new InputFile(videoPath);
var tempThumbnailDir = (_environment.WebRootPath, "temp", "thumbnails");
if (!(tempThumbnailDir)) {
(tempThumbnailDir);
}
var filename = $"{GetFileMetadataMd5(videoPath)}.jpeg";
var outputPath = (tempThumbnailDir, filename);
var outputFile = new OutputFile(outputPath);
var opt = new ConversionOptions {
HideBanner = true,
HWAccelOutputFormatCopy = true,
MapMetadata = true,
};
if (!(outputPath)) {
await (inputFile, outputFile, cancellationToken ?? );
}
return $"temp/thumbnails/{filename}";
}
MD5 hash of the video file
The most straightforward way is to perform an MD5 hash on the entire video file and use the resulting hash value as the filename of the thumbnail. However, if the video file is large, frequent hash calculations may incur significant performance overhead.
public static string GetFileMd5(string filePath) {
using var md5 = ();
using var stream = (filePath);
var hash = (stream);
return (hash).Replace("-", "").ToLowerInvariant();
}
vantage: File content uniqueness ensures that videos with different content do not generate the same thumbnail.
drawbacks: For large files, MD5 calculation takes a long time and affects performance. It takes several seconds for a few gigabytes of video.
MD5 hash of the file path
In order to improve performance, it is also possible to perform MD5 calculations on the file paths only. This approach greatly reduces the amount of computation and is suitable for scenarios where the file content remains the same but thumbnails need to be generated frequently. However, when a file is moved or renamed, the MD5 value generated will be different even though the video content is unchanged, which may lead to unnecessary duplicate thumbnail generation.
string filePathHash;
using (var md5 = ()) {
var pathBytes = Encoding.(videoFilePath);
var hash = (pathBytes);
filePathHash = (hash).Replace("-", "").ToLower();
}
vantage: Efficient, MD5 calculation is extremely fast and suitable for frequent use.
drawbacks: When the file path is changed, a new thumbnail is generated even if the file content remains the same, which may result in redundant thumbnail generation.
MD5 calculation in combination with file attributes
In order to find a balance between path changes and file content uniqueness, Clipify can also combine other attributes of the file, such as filename, modification time, etc. for MD5 calculation. This way, even if the file path changes, as long as the file content and its attributes remain unchanged, the MD5 will not change, avoiding unnecessary regeneration.
public static string GetFileMetadataMd5(string filePath) {
var fileName = (filePath);
var fileInfo = new FileInfo(filePath);
var metaData = fileName + ();
using var md5 = ();
var metaBytes = .(metaData);
var hash = (metaBytes);
return (hash).Replace("-", "").ToLowerInvariant();
}
vantage:
- Combines uniqueness of file contents and file path changes.
- Reduced duplicate thumbnail generation.
drawbacks: Requires combining multiple file attributes and is slightly more complex to compute, but is still effective in improving performance.
wrap-up
In Clipify, choosing how to generate hashes of video thumbnails requires a balance between performance and uniqueness.
For larger video files, direct MD5 computation on the file has a large impact on performance although it ensures the uniqueness of the content.
And by combining file paths and file attributes to generate hashes, you can reduce performance consumption and avoid redundant thumbnail generation.
In subsequent releases, consider using file contents to generate MD5 for small files, and continuing to use combined paths and attributes to generate MD5 for large files.
Show video export progress
I'm using it now.OnProgress
Events, retained to two decimal places
private async void OnProgress(object? sender, ConversionProgressEventArgs e) {
= ;
= ( / * 100, 2);
await InvokeAsync(StateHasChanged);
}
For a more detailed display of additional information during processing, see the previous section on interacting with FFmpeg.
particulars
During the design process of Clipify, I paid a lot of attention to the details in user experience, especially how to make users understand the properties of video files more intuitively and easily. Therefore, in addition to the basic video editing functions, I also optimized the display of file size and video length in the interface.
These two points have been chosen to be presented in this article:
- Show more friendly file sizes
- Show more friendly video length
Show more friendly file sizes
Video files are often large and displaying the size in bytes may not be intuitive. To improve the user experience, I chose to convert the file size to more common units such as KB, MB or GB and use rounding to make the display more concise.
For example, if the video file size is 3,304,582 bytes, it would appear as3.30 MB
. In this way, the user does not need to convert units and can directly see the approximate size of the file.
Here I have written an extension method to do so.
public static class FileInfoExtensions {
public static string GetFriendlySize(this FileInfo fileInfo) {
string[] sizeUnits = { "Bytes", "KB", "MB", "GB", "TB" };
double fileSize = ;
int unitIndex = 0;
while (fileSize >= 1024 && unitIndex < - 1) {
fileSize /= 1024;
unitIndex++;
}
return $"{fileSize:F2} {sizeUnits[unitIndex]}";
}
}
effect:
- Large files are displayed in MB or GB, while small files are displayed in KB, ensuring a more accurate visualization of file size for the user.
- The user interface is clearer and neater, avoiding unnecessary visual burden.
Show more friendly video length
For the length of a video file, displaying it directly in seconds or milliseconds is not user-friendly. In order to provide a more intuitive experience, I chose to convert the video length to a formatted time display such asHH:mm:ss
, allowing users to quickly understand the length of the video.
For example, a video that is 5 minutes and 44 seconds long is displayed as00:05:44
Instead of displaying the number of seconds directly (e.g. 344 seconds). This display is in line with the user's daily cognitive habits and allows the user to more easily estimate the time span of the video content.
Still using the extension method for this (I even wrote the English version)
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 ( > 0)
($"{}day");;
if ( > 0)
($"{}hours");
if ( > 0)
($"{}Minutes"); ($"{}Minutes").
if ( > 0)
($"{}seconds");
// If there are no days, hours, minutes, or seconds, display as 0 seconds
if ( == 0)
return "0 seconds";
break.
default.
if ( > 0)
($"{} day{( > 1 ? "s" : "")}"); ($"{} day{( > 1 ?
if ( > 0)
($"{} hour{( > 1 ? "s" : "")}");
if ( > 0)
($"{} minute{( > 1 ? "s" : "")}"); ($"{} minute{( > 1 ? "s" : "")}")
if ( > 0)
($"{} second{( > 1 ? "s" : "")}"); ($"{} second{( > 1 ? "s" : "")}")
// If there are no days, hours, minutes or seconds, display 0 seconds
if ( == 0)
return "0 seconds";
break;
}
return (", ", parts); }
}
}
But if you want to fix the format, you can just use a shorter code:
public static string FormatVideoDuration(TimeSpan duration)
{
return (
"{0:D2}:{1:D2}:{2:D2}",
,
,
);
}
wrap-up
It's the details that define the experience. In Clipify's design, displaying more user-friendly file sizes and video lengths was a key step in enhancing the user experience. By translating technical logic into intuitive interface elements, users can manipulate video files more easily and reduce the distress caused by unintuitive information. Optimizing these small details will help improve the overall ease of use and user satisfaction of the tool.
Article Summary
Compared to the previous QuickCutSharp, this new tool is more flexible in terms of development experience and interface design, and more suitable for my needs. Although I initially tried some other development solutions, such as Avalonia and MAUI, I eventually gave up due to the complexity of the environment or unsupported platforms.
Using Blazor and TailwindCSS to build the interface maintains the familiar C# development ecosystem while bringing a modernized front-end experience, which makes the development of the whole project much smoother. Although Clipify is only partially functional at the moment, I'm looking forward to its future development. The project has been open-sourced, and I hope it can provide some help to developers with similar needs.
bibliography
- /en-us/aspnet/core/blazor/hybrid/tutorials/windows-forms?view=aspnetcore-8.0
- /jbogard/MediatR/wiki
- /cmxl/
- /dotnet/maui/tree/main/src/BlazorWebView/samples/BlazorWinFormsApp
- /rosenbjerg/FFMpegCore
- /zh-CN/components/overview