【Tauri】大家一起來做一個簡單的影片轉檔工具吧…
前言
其實在之前,一直很想用Wails做一個簡單的ffmpeg影片轉換器
,但後來發現網路有關Tauri的資源好像滿多的,而且聽說Rust
很安全,用了之後才發現是真的安全,在編譯的時候全都從頭到尾檢查一遍,導致編譯的速度真的是很慢啊,而且檔案真的是大,動不動就是好幾G的…
作業環境
項目 | 版本 |
---|---|
macOS | Sequoia 15.5 |
Visual Studio Code | 1.101.2 |
ffmpeg | 7.1.1 |
nodejs | 22.14.0 |
rust | 1.88 |
tauri | 2.6.2 |
FFmpeg影片轉檔工具
先前準備工作
brew install node
brew install rust
brew install ffmpeg
cargo install create-tauri-app
- 利用create-tauri-app來建立一個名叫
tauri_ffmpeg_gui
的專案,使用的是Vue3 + Typescript… - 過程這邊就不多做說明了,可以參考上一篇文章…
cd ~/Desktop
cargo create-tauri-app tauri_ffmpeg_gui --template vue-ts
cd tauri_ffmpeg_gui
npm install naive-ui
- 範例這用這一部影片,就請先自行下載囉…
http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
/// src-tauri/src/Cargo.toml
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4.41"
once_cell = "1.21.3"
nix = { version = "0.27", features = ["signal"] }
windows-sys = { version = "0.60.2", features = ["Win32_System_Threading"] }
- 最後,就讓它跑一下試試看…
npm install
npm run tauri dev
監聽tauri的drag-drop功能
- 這裡要監聽
tauri://drag-drop
事件,來取得拖曳時的反應… - 我們就來新增一個
filePath
變數,來儲存拖曳進來的檔案路徑… - 主要的內容就是
listenDragDrop()
這段程式碼…
// src/App.vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
const filePath = ref("");
enum TauriEvent {
DragDrop = "tauri://drag-drop"
}
function listenDragDrop() {
listen(TauriEvent.DragDrop, (event: any) => {
if (event?.payload?.paths.length > 0) {
filePath.value = event.payload.paths[0];
}
});
}
onMounted(async () => {
listenDragDrop();
});
</script>
<template>
<div class="main-drop-area">
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<input v-model="filePath" placeholder="檔案路徑會顯示在這裡" style="flex: 1;" />
<button type="button" style="margin-left: 8px;">轉換</button>
</div>
</div>
</template>
- 有關CSS的部分也附在這裡…
<style scoped>
.main-drop-area {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
bottom: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed #888;
background: transparent;
}
.startBtn {
background: #43a047 !important;
color: #fff !important;
border: 1px solid #1b5e20 !important;
}
.stopBtn {
background: #e53935 !important;
color: #fff !important;
border: 1px solid #b71c1c !important;
}
.log-segment {
border-bottom: 1px solid #444;
padding: 4px 0 8px 0;
margin-bottom: 4px;
word-break: break-all;
}
.log-segment:last-child {
border-bottom: none;
}
</style>
<style>
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}
</style>
執行系統的指令功能
- 這個APP最主要的就是要執行系統的指令功能,讓使用者可以透過APP來執行系統的指令…
- 比如說,可以透過APP來執行ffmpeg指令,將影片轉換成不同的格式。
ffmpeg -i input.mp4 -c:v libx264 -pix_fmt yuv420p -c:a aac output.mp4
- 在上一步中,我們取得了影片檔的位置,接著就可以透過APP來執行ffmpeg指令,將影片轉換成不同的格式。
- 就是按下
轉換
按鈕後,APP會開始執行ffmpeg指令,將影片轉換成不同的格式。 - 在這邊我們就取名為
start_convert()
,並在按下按鈕後rust端的start_convert(path:)
。
/// src/App.vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen, UnlistenFn } from "@tauri-apps/api/event";
const filePath = ref("");
enum TauriEvent {
DragDrop = "tauri://drag-drop"
}
function listenDragDrop() {
listen(TauriEvent.DragDrop, (event: any) => {
if (event?.payload?.paths.length > 0) {
filePath.value = event.payload.paths[0];
}
});
}
async function start_convert() {
let result = await invoke("start_convert", { path: filePath.value });
console.log(`result = ${result}`)
}
onMounted(async () => {
listenDragDrop();
});
</script>
<template>
<div class="main-drop-area">
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<input v-model="filePath" placeholder="檔案路徑會顯示在這裡" style="flex: 1;" />
<button type="button" style="margin-left: 8px;" @click="start_convert">轉換</button>
</div>
</div>
</template>
- 接著就是組合成啟動shell的完整指令,就是類似長這樣子
sh -c ffmpeg -i <path> output.mp4
- 這裡輸出的std::process::Command就是真的要執行的功能…
/// src-tauri/src/library/_process.rs
use std::process::Command;
pub fn command_combine(command: &str) -> Command {
if cfg!(windows) {
let mut cmd = Command::new("cmd");
cmd.arg("/C");
cmd.arg(command);
cmd
} else {
let mut cmd = Command::new("sh");
cmd.arg("-c");
cmd.arg(command);
cmd
}
}
- 再來就是要產生ffmpeg指令的細節,將影片轉成
output.mp4
… - 目前的指令是長這樣子
ffmpeg -i <path> output.mp4
- 啟動指令就是
command.output()
…
/// src-tauri/src/utility/_command.rs
use std::{str};
use crate::library:: {
_process::{command_combine}
};
pub fn ffmpeg_command_maker(path: &str) -> String {
let setting = "";
let ffmpeg_cmd = format!("ffmpeg -i {} {} {}", path, setting, "output.mp4");
let mut command = command_combine(ffmpeg_cmd.as_str());
let _ = command.output().expect("failed to execute process");
ffmpeg_cmd
}
- 啟動後會發現,雖然可以成功執行,但產生的output.mp4會在
src-tauri/target
目錄之下…
- 而且,APP會卡住,一定要等它轉換完成才能做其它事,就一直轉圈圈,有點像當掉的感覺…
- 其實因為指令是在前台處理,所以APP會卡住,所以後面會把指令轉在後台執行…
- 在Linux下,在指令後面加上
&
就可以在背景執行了,不然正常應該要等它跑完才能換下一個指令,大家可以試試看…
/// src-tauri/src/utility/_command.rs
use std::{str};
use crate::library:: {
_process::{command_combine}
};
pub fn ffmpeg_command_maker(path: &str) -> String {
let setting = "-c:v libx264 -c:a aac";
let ffmpeg_cmd = format!("ffmpeg -i {} {} {}", path, setting, "output.mp4");
let mut command = command_combine(ffmpeg_cmd.as_str());
// let _ = command.output().expect("failed to execute process");
let _ = command.spawn();
ffmpeg_cmd
}
- 再執行一次就可以發現,APP不會卡住,可以做其它事…
- 也可以看到ffmpeg的進度在下方的
terminal
上出現…
檔案輸出
- 將轉換完成的檔案輸出到跟原始檔案相同的路徑…
- 當然檔案名要改一下,不然就會不能轉換了…
- 簡單來說,就是把要轉換檔案的目錄取出來,然後再自訂一個輸出檔名…
- 這裡我們以
<filename>_YYYYMMDD_HHMMSS.<format>
做為輸出的檔名…
/// src-tauri/src/utility/_command.rs
use std::{str};
use std::io::Error;
use crate::library:: {
_process::{command_combine},
_path::{full_path_maker},
};
pub fn ffmpeg_command_maker(path: &str) -> Result<String, Error> {
let setting = "-c:v libx264 -pix_fmt yuv420p -c:a aac";
let output_path = full_path_maker(path, "mp4")?;
let ffmpeg_cmd = format!("ffmpeg -i {} {} {}", path, setting, output_path.display());
let mut command = command_combine(ffmpeg_cmd.as_str());
let _ = command.spawn();
Ok(ffmpeg_cmd)
}
- 接下來就來就是快速說明
full_path_maker()
是怎麼產生檔名路徑的… - 首先是做一個error的產生器…
/// src-tauri/src/library/_error.rs
use std::io::{Error, ErrorKind};
pub fn io_error_maker(kind: ErrorKind, message: &str) -> Error {
Error::new(kind, message)
}
- 接下來就是產生時間後綴字串…
/// src-tauri/src/library/_string.rs
use chrono;
pub fn timestamp_filename(prefix: &str, format: &str) -> String {
let now = chrono::Local::now();
let datetime_str = now.format("%Y%m%d_%H%M%S").to_string();
format!("{}_{}.{}", prefix, datetime_str, format)
}
- 最後就是產生檔名路徑字串…
/// src-tauri/src/library/_path.rs
use std::path::{Path, PathBuf};
use std::io::{Error, ErrorKind};
use crate::library::_string::timestamp_filename;
use crate::library::_error::io_error_maker;
pub fn file_exists(file_path: &str) -> bool {
Path::new(file_path).exists()
}
pub fn file_parent_dir(file_path: &str) -> Option<&Path> {
Path::new(file_path).parent()
}
pub fn file_stem(file_path: &str) -> Option<&str> {
Path::new(file_path).file_stem().and_then(|s| s.to_str())
}
pub fn full_path_maker(path: &str, format: &str) -> Result<PathBuf, Error> {
let dir_path = match file_parent_dir(path) {
Some(dir) => dir,
None => {
let error_message = &format!("無法取得父目錄: {}", path);
return Err(io_error_maker(ErrorKind::NotFound, error_message));
},
};
let prefix = file_stem(path).unwrap_or("output");
let filename = timestamp_filename(prefix, format);
Ok(dir_path.join(filename))
}
- 可以試試看,看看是不是可以輸出檔案到外部了?
讀取進度
- 大家可以發現,雖然的確是在轉換檔案了,但是輸出的文字資訊卻只能在terminal中看到…
- 像這樣子完全看不到過程,也無法掌握到底是不是轉換完成了,所以我們必須把輸出在terminal的資訊擷取出來…
- 不過因為這中間的過程是
一直在輸出
的,所以不能用一般return
的方式來取得資訊,而是要用emit()的來取得資訊,也就是使用Inter-Process Communication - 跨程序通訊 (IPC)來處理…
- 接下來就把輸出資訊改成emit的方式來處理…
- 主要就是
emit_payload()
的使用,很像使用Notification的訂閱方式來處理…
/// src-tauri/src/library/_tauri.rs
use serde::Serialize;
use tauri::{Emitter, Window};
pub fn emit_payload<S: Serialize + Clone>(target: &Window, event: &str, payload: S) {
let _ = target.emit(event, payload);
}
- 所以呢,就把
start_convert()
改成emit_payload()
的方式來處理,這樣我們就把資訊飛鴿傳書傳給前端了… - 這裡先設定兩個事件,分別是
error
和finish
…
/// src-tauri/src/lib.rs
#[tauri::command]
fn start_convert(window: Window, path: &str) {
let window_clone = window.clone();
if !file_exists(path) {
let error = io_error_maker(ErrorKind::NotFound, "檔案不存在");
return emit_payload(&window_clone, "error", format!("{:?}", error));
}
let cmd_str = match ffmpeg_command_maker(path) {
Ok(cmd) => cmd,
Err(err) => return emit_payload(&window_clone, "error", format!("{:?}", err)),
};
return emit_payload(&window_clone, "finish", cmd_str);
}
- 那在網頁端要怎麼收信呢?
- 我們使用
UnlistenFn
來處理,其實它就是一個closure,把Unlisten的功能記下來,之後才能中斷它… - 所以呢就會有註冊事件監聽用的
registerListener()
,跟解除事件監聽用的unregisterListener()
… - 所以
start_convert()
也不用接回傳值了,而是由handleCommandError()
來處理…
/// src/App.vue (部分)
enum FFmpegEvent {
Error = "error",
Finish = "finish",
Progress = "progress",
}
const logs = ref<string[]>([]);
const unlistenError = ref<UnlistenFn | null>(null);
const logBox = ref<HTMLElement | null>(null);
const watchLogs = computed(() => {
return [...logs.value];
});
async function registerListener() {
unlistenError.value = await handleCommandError();
}
function unregisterListener() {
if (unlistenError.value) { unlistenError.value(); }
}
async function start_convert() {
await invoke("start_convert", { path: filePath.value });
}
async function handleCommandError() {
return await listen<string>(FFmpegEvent.Error, (event: any) => {
const payload = event.payload as string;
logs.value.push(payload);
});
}
watch(watchLogs, () => {
setTimeout(() => {
if (logBox.value) { logBox.value.scrollTop = logBox.value.scrollHeight; }
}, 0);
});
onMounted(async () => {
registerListener();
});
onUnmounted(() => {
unregisterListener();
});
- 而在畫面方面呢,就加一個叫
logBox
的div
,用來顯示訊息… - 當rust傳出錯誤訊息時,就用
logs
把它記下來,順便顯示… - 然後啊,也希望可以讓使用者可以點擊訊息後,訊息可以一直自動捲到最下面…
- 所以加上watch的來監聽變動的
watchLogs
,在每次變動時自動捲動到最下面… - 大家可以試試看,如果不使用setTimeout(),或者只是監聽
logs
的話,會有什麼變化呢?
/// src/App.vue (部分)
<template>
<div class="main-drop-area">
<div ref="logBox" style="margin-top: 20px; width: 90%; background: #222; color: #fff; font-size: 14px; border-radius: 6px; padding: 8px; height: 200px; max-height: 200px; overflow: auto;">
<div v-for="(line, idx) in logs" :key="idx" class="log-segment">{{ line }}</div>
</div>
</div>
</template>
處理完整的指令訊息
- 接下來要處理完整的指令訊息
- 也就是處理
error
/finish
/progress
這三種狀態訊息…
/// src/App.vue (部分)
enum FFmpegEvent {
Error = "error",
Finish = "finish",
Progress = "progress",
}
const unlistenFunctions = ref<UnlistenFn[]>([]);
async function registerListener() {
unlistenFunctions.value.push(await handleCommandError());
unlistenFunctions.value.push(await handleCommandFinish());
unlistenFunctions.value.push(await handleCommandProgress());
}
function unregisterListener() {
unlistenFunctions.value.forEach((handle: () => void) => { handle(); });
}
async function handleCommandFinish() {
return await listen<string>(FFmpegEvent.Finish, (event: any) => {
const payload = event.payload as string;
logs.value.push(payload);
});
}
async function handleCommandProgress() {
return await listen<string>(FFmpegEvent.Progress, (event: any) => {
const payload = event.payload as string;
logs.value.push(payload);
});
}
async function handleCommandError() {
return await listen<string>(FFmpegEvent.Error, (event: any) => {
const payload = event.payload as string;
isConverting.value = false;
logs.value.push(payload);
});
}
async function handleCommandStart() {
return await listen<string>(FFmpegEvent.Start, (event: any) => {
const payload = event.payload as string;
logs.value.push(payload);
});
}
- 首先呢,我們要把顯示在terminal上的訊息,轉到管線 - Stdio::piped()上處理,也就是子行程,這樣我們才能不卡卡,進而取得相關的訊息…
- 然後呢,也把
Command
傳出去,再做更進一步的處理…
/// src-tauri/src/utility/_command.rs (部分)
use std::{
str,
io::Error,
process::{Command, Stdio}
};
use crate::library:: {
_process::{command_combine},
_path::{full_path_maker},
};
pub fn ffmpeg_command_maker(path: &str) -> Result<(Command, String), Error> {
let setting = "-c:v libx264 -c:a aac";
let output_path = full_path_maker(path, "mp4")?;
let ffmpeg_cmd = format!("ffmpeg -i {} {} {}", path, setting, output_path.display());
let mut command = command_combine(ffmpeg_cmd.as_str());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
Ok((command, ffmpeg_cmd))
}
- 這裡也定義了三種事件,分別是
錯誤 - error
、完成 - finish
和進度 - progress
…
/// src-tauri/src/utility/_constant.rs (部分)
pub enum FFmpegEvent {
Error,
Finish,
Progress,
}
impl FFmpegEvent {
pub fn as_str(&self) -> &'static str {
match self {
FFmpegEvent::Error => "error",
FFmpegEvent::Finish => "finish",
FFmpegEvent::Progress => "progress",
}
}
}
- 這邊最主要的就是
child_log()
,用來處理子進程的輸出… - 一般來說呢,大部分的terminal訊息都會放到
stderr
做輸出,所以我們只取stderr
的訊息… - 然後以
\r
跟\n
來分隔訊息,就是return
跟enter
… - 至於為什麼會有\r呢?因為有些訊息會一直在同一行顯示、更新的…
/// src-tauri/src/lib.rs (部分)
mod utility;
mod library;
use std:: {
io::{ErrorKind, Read},
process::Child,
};
use tauri::Window;
use library::{
_error::io_error_maker,
_path::file_exists,
};
use utility::{
_command::ffmpeg_command_maker,
_tauri::emit_payload,
_constant::FFmpegEvent,
};
#[tauri::command]
fn start_convert(window: Window, path: &str) {
let window_clone = window.clone();
if !file_exists(path) {
let error = io_error_maker(ErrorKind::NotFound, "檔案不存在");
return emit_payload(&window_clone, "error", format!("{:?}", error));
}
let (mut cmd, cmd_str) = match ffmpeg_command_maker(path) {
Ok(result) => result,
Err(error) => return emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}", error)),
};
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(error) => return emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}", error)),
};
child_log(&mut child, |line| {
if !line.is_empty() { emit_payload(&window_clone, FFmpegEvent::Progress.as_str(), line); }
});
emit_payload(&window_clone, FFmpegEvent::Finish.as_str(), &cmd_str)
}
fn child_log<F: Fn(&str)>(child: &mut Child, closure: F) {
if let Some(mut stderr) = child.stderr.take() {
let mut buffer = [0u8; 4096];
let mut partial = String::new();
loop {
match stderr.read(&mut buffer) {
Err(_) => break,
Ok(0) => break,
Ok(size) => {
let chunk = String::from_utf8_lossy(&buffer[..size]);
partial.push_str(&chunk);
partial_line_action(&mut partial, &closure);
}
}
}
}
}
fn partial_line_action<F: Fn(&str)>(partial: &mut String, closure: F) {
while let Some(index) = partial.find(|char| char == '\r' || char == '\n') {
let line = partial[..index].to_string();
closure(&line);
*partial = partial[index + 1..].to_string();
}
}
- 我們就來試試看吧,是不是會有相關的訊息顯示出來了呢?
指令訊息處理
- 雖然我們能取得完整的指令訊息了,但是…它是最後才一次出現的,並沒有像使用指令一般,一直有訊息提示…
- 為什麼呢?聰明如你一定想到了,因為取得指令訊息不是在子進程中處理的,所以…我們要開個新的進程來處理它,它會在背景執行…
- 我們來使用
thread::spawn()
來開個新的進程來處理指令訊息… - 而它的參數
move
,如果沒有move
,thread 內部閉包就不能直接用外部的變數(除非它是 ‘static 或 Copy),它把所有權 - Ownership移進來了,功能上有點有Objective-C
的__block的味道…
/// src-tauri/src/lib.rs (部分)
fn start_convert(window: Window, path: &str) {
let window_clone = window.clone();
if !file_exists(path) {
let error = io_error_maker(ErrorKind::NotFound, "檔案不存在");
return emit_payload(&window_clone, "error", format!("{:?}", error));
}
let (mut cmd, cmd_str) = match ffmpeg_command_maker(path) {
Ok(result) => result,
Err(error) => return emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}", error)),
};
thread::spawn(move || {
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(error) => return emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}", error)),
};
child_log(&mut child, |line| {
if !line.is_empty() { emit_payload(&window_clone, FFmpegEvent::Progress.as_str(), line); }
});
emit_payload(&window_clone, FFmpegEvent::Finish.as_str(), &cmd_str)
});
}
取消指令
- 雖然能執行了,但是如果再按一次的話,又會跑一個新的進程耶…
- 有沒有辦法把它關掉呢?請使用
kill
命令… - 我們知道要使用kill命令的話,就是要取得PID - 進程ID…
- 所以我們在程式裡也如法炮製一般…
- 先建立
kill_process(pid:)
這個終止正在運行的子進程功能…
/// src-tauri/src/library/_process.rs (部分)
use std::{
ptr::null_mut,
};
pub fn kill_process(pid: u32) {
#[cfg(unix)] { kill_unix_process(pid as i32); }
#[cfg(windows)] { kill_windows_process(pid); }
}
#[allow(dead_code)]
fn kill_unix_process(pid: i32) {
use nix::sys::signal::{kill, Signal};
use nix::unistd::Pid;
let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL);
}
#[allow(dead_code)]
fn kill_windows_process(pid: u32) {
use windows_sys::Win32::System::Threading::PROCESS_TERMINATE;
use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess};
unsafe {
let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
if handle != null_mut() {
let _ = TerminateProcess(handle, 1);
}
}
}
- 然後呢,把執行後的
GLOBAL_PID
記下來,然後再使用kill_process(pid:)
來終止它… - 因為有執行中的
子進程ID
只會有一個,所以要使用once_cell
跟Mutex
來處理它的唯一性…
mod library;
use std::{
sync::Mutex,
};
use tauri::Window;
use once_cell::sync::Lazy;
use library::{
_process::kill_process,
};
static GLOBAL_PID: Lazy<Mutex<Option<u32>>> = Lazy::new(|| Mutex::new(None));
fn global_pid_lock() -> std::sync::MutexGuard<'static, Option<u32>> {
GLOBAL_PID.lock().unwrap()
}
#[tauri::command]
fn start_convert(window: Window, path: &str) {
let window_clone = window.clone();
if !file_exists(path) {
let error = io_error_maker(ErrorKind::NotFound, "檔案不存在");
return emit_payload(&window_clone, "error", format!("{:?}", error));
}
let (mut cmd, cmd_str) = match ffmpeg_command_maker(path) {
Ok(result) => result,
Err(error) => return emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}", error)),
};
thread::spawn(move || {
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(error) => return emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}", error)),
};
{
let mut global = global_pid_lock();
*global = Some(child.id());
}
child_log(&mut child, |line| {
if !line.is_empty() { emit_payload(&window_clone, FFmpegEvent::Progress.as_str(), line); }
});
let output = match child.wait_with_output() {
Ok(output) => output,
Err(error) => {
emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}",error));
let mut global = global_pid_lock();
return *global = None;
}
};
emit_payload(&window_clone, FFmpegEvent::Finish.as_str(), format!("{:?}", output));
emit_payload(&window_clone, FFmpegEvent::Finish.as_str(), &cmd_str)
});
}
#[tauri::command]
fn stop_convert() {
let mut pid_opt = GLOBAL_PID.lock().unwrap();
if let Some(pid) = pid_opt.take() { kill_process(pid); }
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![start_convert, stop_convert])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
- 再來就是在畫面上的顯示了…
- 我們在按鈕上做顯示的處理,用
isConverting
來處理結束
跟轉換
之間功能的處理…
/// src/App.vue (部分)
enum RustApi {
StartConvert = "start_convert",
StopConvert = "stop_convert",
}
const isConverting = ref(false);
function handleConvert() {
if (isConverting.value) { stop_convert(); return; }
start_convert();
}
async function handleCommandFinish() {
return await listen<string>(FFmpegEvent.Finish, (event: any) => {
const payload = event.payload as string;
isConverting.value = false;
logs.value.push(payload);
});
}
async function start_convert() {
logs.value = [];
isConverting.value = true;
await invoke(RustApi.StartConvert, { path: filePath.value });
}
async function stop_convert() {
await invoke(RustApi.StopConvert);
}
</script>
/// src/App.vue (部分)
<template>
<div class="main-drop-area">
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<input v-model="filePath" placeholder="檔案路徑會顯示在這裡" style="flex: 1;" />
<button type="button" style="margin-left: 8px;" :class="isConverting ? 'stopBtn' : 'startBtn'" @click="handleConvert">
{{ isConverting ? '結束' : '轉換' }}
</button>
</div>
<div ref="logBox" class="log-box" style="margin-top: 12px;">
<div v-for="(line, idx) in logs" :key="idx" class="log-segment">{{ line }}</div>
</div>
</div>
</template>
- 然後就來試試是不是能中途停止轉換了?
- 到這裡為止,主功能都完成,剩下的就是
ffmpeg
的參數使用了…
參數設定
- 先增加轉檔的格式 / 編碼格式選項…
/// src/App.vue (部分)
const format = ref("ts");
const encode= ref("copy");
const formatOptions = [
{ value: "ts", label: ".ts" },
{ value: "mp4", label: ".mp4" },
{ value: "mkv", label: ".mkv" },
{ value: "mov", label: ".mov" },
];
const encodeOptions = [
{ value: "copy", label: "copy" },
{ value: "h264", label: "h264" },
{ value: "h265", label: "h265" },
];
/// src/App.vue (部分)
<template>
<div class="main-drop-area">
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<input v-model="filePath" placeholder="檔案路徑會顯示在這裡" style="flex: 1;" />
<select v-model="format" class="select-option">
<option v-for="option in formatOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<select v-model="format" class="select-option">
<option v-for="option in encodeOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<button type="button" style="margin-left: 8px;" :class="isConverting ? 'stopBtn' : 'startBtn'" @click="handleConvert">
{{ isConverting ? '結束' : '轉換' }}
</button>
</div>
<div ref="logBox" class="log-box" style="margin-top: 12px;">
<div v-for="(line, idx) in logs" :key="idx" class="log-segment">{{ line }}</div>
</div>
</div>
</template>
<style scoped>
.select-option {
margin-left: 8px;
padding: 0.6em 1.2em;
border-radius: 8px;
border: 1px solid #bbb;
background: #0f0f0f;
color: #f0f0f0;
font-size: 1em;
font-family: inherit;
cursor: pointer;
height: 44px;
line-height: 44px
}
</style>
/// src/App.vue (部分)
import { onMounted, onUnmounted, ref, computed, watch, reactive } from "vue";
const startTime = reactive({ hour: 0, minute: 0, second: 0 });
const endTime = reactive({ hour: 23, minute: 59, second: 59 });
<template>
<div class="main-drop-area">
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<span style="margin-right: 8px;">開始</span>
<input type="number" min="0" max="23" v-model.number="startTime.hour" style="width: 60px;" placeholder="時" @input="onHourChange(startTime, 0, 23)" :value="displayTime(startTime.hour)" />
<span style="margin: 0 8px;">:</span>
<input type="number" min="0" max="59" v-model.number="startTime.minute" style="width: 60px;" placeholder="分" @input="onMinuteChange(startTime, 0, 59)" :value="displayTime(startTime.minute)" />
<span style="margin: 0 8px;">:</span>
<input type="number" min="0" max="59" v-model.number="startTime.second" style="width: 60px;" placeholder="秒" @input="onSecondChange(startTime, 0, 59)" :value="displayTime(startTime.second)" />
</div>
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<span style="margin-right: 8px;">結束</span>
<input type="number" min="0" max="23" v-model.number="endTime.hour" style="width: 60px;" placeholder="時" @input="onHourChange(startTime, 0, 23)" :value="displayTime(endTime.hour)" />
<span style="margin: 0 8px;">:</span>
<input type="number" min="0" max="59" v-model.number="endTime.minute" style="width: 60px;" placeholder="分" @input="onMinuteChange(startTime, 0, 59)" :value="displayTime(endTime.minute)" />
<span style="margin: 0 8px;">:</span>
<input type="number" min="0" max="59" v-model.number="endTime.second" style="width: 60px;" placeholder="秒" @input="onSecondChange(startTime, 0, 59)" :value="displayTime(endTime.second)" />
</div>
</div>
</template>
<style scoped>
.log-box {
width: 90%;
background: #222;
color: #fff;
font-size: 14px;
border-radius: 6px;
padding: 8px;
height: 200px;
max-height: 200px;
overflow: auto;
}
</style>
- 加上影片尺寸的設定…
/// src/App.vue (部分)
import { onMounted, onUnmounted, ref, computed, watch, reactive } from "vue";
import { NSwitch } from "naive-ui";
const videoSize = reactive({ width: 1920, height: 1080 });
const videoSizeUsed = ref(false);
/// src/App.vue (部分)
</template>
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<span style="margin-right: 8px;">尺寸</span>
<input type="number" min="1" v-model.number="videoSize.width" style="width: 60px;" placeholder="寬"/>
<span style="margin: 0 8px;">:</span>
<input type="number" min="1" v-model.number="videoSize.height" style="width: 60px;" placeholder="高"/>
<n-switch v-model.number="videoSizeUsed" style="margin-left: 16px;">
<template #checked>ON</template>
<template #unchecked>OFF</template>
</n-switch>
</div>
</div>
</template>
/// src/App.vue (部分)
function displayTime(value: number) {
return value < 10 ? `0${value}` : `${value}`;
}
function onHourChange(refVar: any, min: number, max: number) {
refVar.hour = Math.max(min, Math.min(max, refVar.hour));
}
function onMinuteChange(refVar: any, min: number, max: number) {
refVar.minute = Math.max(min, Math.min(max, refVar.minute));
}
function onSecondChange(refVar: any, min: number, max: number) {
refVar.second = Math.max(min, Math.min(max, refVar.second));
}
function onWidthChange(refVar: any, min: number) {
refVar.width = Math.max(min, refVar.width);
}
function onHeightChange(refVar: any, min: number) {
refVar.height = Math.max(min, refVar.height);
}
/// src/App.vue (部分)
<template>
<div class="main-drop-area">
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<input v-model="filePath" placeholder="檔案路徑會顯示在這裡" style="flex: 1;" />
<select v-model="format" class="select-option">
<option v-for="option in formatOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<select v-model="encode" class="select-option">
<option v-for="option in encodeOptions" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<button type="button" style="margin-left: 8px;" :class="isConverting ? 'stopBtn' : 'startBtn'" @click="handleConvert">
{{ isConverting ? '結束' : '轉換' }}
</button>
</div>
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<span style="margin-right: 8px;">開始</span>
<input type="number" min="0" max="23" v-model.number="startTime.hour" style="width: 60px;" placeholder="時" @input="onHourChange(startTime, 0, 23)" :value="displayTime(startTime.hour)" />
<span style="margin: 0 8px;">:</span>
<input type="number" min="0" max="59" v-model.number="startTime.minute" style="width: 60px;" placeholder="分" @input="onMinuteChange(startTime, 0, 59)" :value="displayTime(startTime.minute)" />
<span style="margin: 0 8px;">:</span>
<input type="number" min="0" max="59" v-model.number="startTime.second" style="width: 60px;" placeholder="秒" @input="onSecondChange(startTime, 0, 59)" :value="displayTime(startTime.second)" />
</div>
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<span style="margin-right: 8px;">結束</span>
<input type="number" min="0" max="23" v-model.number="endTime.hour" style="width: 60px;" placeholder="時" @input="onHourChange(startTime, 0, 23)" :value="displayTime(endTime.hour)" />
<span style="margin: 0 8px;">:</span>
<input type="number" min="0" max="59" v-model.number="endTime.minute" style="width: 60px;" placeholder="分" @input="onMinuteChange(startTime, 0, 59)" :value="displayTime(endTime.minute)" />
<span style="margin: 0 8px;">:</span>
<input type="number" min="0" max="59" v-model.number="endTime.second" style="width: 60px;" placeholder="秒" @input="onSecondChange(startTime, 0, 59)" :value="displayTime(endTime.second)" />
</div>
<div style="display: flex; width: 90%; align-items: center; margin-top: 12px;">
<span style="margin-right: 8px;">尺寸</span>
<input type="number" min="-1" v-model.number="videoSize.width" style="width: 60px;" placeholder="寬" @input="onWidthChange(videoSize, -1)" :value="videoSize.width" />
<span style="margin: 0 8px;">:</span>
<input type="number" min="-1" v-model.number="videoSize.height" style="width: 60px;" placeholder="高" @input="onHeightChange(videoSize, -1)" :value="videoSize.height" />
<n-switch v-model.number="videoSizeUsed" style="margin-left: 16px;">
<template #checked>ON</template>
<template #unchecked>OFF</template>
</n-switch>
</div>
<div ref="logBox" class="log-box" style="margin-top: 12px;">
<div v-for="(line, idx) in logs" :key="idx" class="log-segment">{{ line }}</div>
</div>
</div>
</template>
- 然後再加上自訂
ffmpeg
的路徑,因為在打包之後,是讀取APP上的ffmpeg路徑的,當然就沒反應啊…
/// src/App.vue (部分)
const ffmpegPath = ref("/opt/homebrew/bin/ffmpeg");
/// src/App.vue (部分)
<template>
<div class="main-drop-area">
<div style="display: flex; width: 90%; align-items: center;">
<input v-model="ffmpegPath" placeholder="ffmpeg路徑會顯示在這裡" style="flex: 1;" />
</div>
</div>
</template>
進階參數設定
- 在網頁端,我們就把該傳的數值傳給後端,後端再傳給ffmpeg…
/// src/App.vue (部分)
async function start_convert() {
const start_time = `${startTime.hour}:${startTime.minute}:${startTime.second}`;
const end_time = `${endTime.hour}:${endTime.minute}:${endTime.second}`;
const scale = videoSizeUsed ? `${videoSize.width}:${videoSize.height}` : ``
logs.value = [];
isConverting.value = true;
await invoke(RustApi.StartConvert, {
command: ffmpegPath.value,
path: filePath.value,
startTime: start_time,
endTime: end_time,
format: format.value,
encode: encode.value,
scale: scale
});
}
function initValue() {
logs.value = [];
isConverting.value = false;
}
onMounted(async () => {
listenDragDrop();
await registerListener();
initValue();
});
- 然後我們把傳入的參數傳遞給
start_convert_action
函數…
// src-tauri/src/lib.rs
fn start_convert_action(window: Window, command: &str, path: &str, start_time: &str, end_time: &str, format: &str, encode: &str, scale: &str) {
let window_clone = window.clone();
if !file_exists(path) {
let error = io_error_maker(ErrorKind::NotFound, "檔案不存在");
emit_payload(&window_clone, "error", format!("{:?}", error)); return;
}
let (mut cmd, cmd_str) = match ffmpeg_command_maker(command, path, start_time, end_time, format, encode, scale) {
Ok(result) => result,
Err(error) => { emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}", error)); return; },
};
thread::spawn(move || {
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(error) => { emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}", error)); return; },
};
{
let mut global = global_pid_lock();
*global = Some(child.id());
}
child_log(&mut child, |line| {
if !line.is_empty() { emit_payload(&window_clone, FFmpegEvent::Progress.as_str(), line); }
});
let output = match child.wait_with_output() {
Ok(output) => output,
Err(error) => {
emit_payload(&window_clone, FFmpegEvent::Error.as_str(), format!("{:?}",error));
let mut global = global_pid_lock();
*global = None; return;
}
};
emit_payload(&window_clone, FFmpegEvent::Finish.as_str(), format!("{:?}", output));
emit_payload(&window_clone, FFmpegEvent::Finish.as_str(), &cmd_str);
});
}
#[tauri::command]
fn start_convert(window: Window, command: &str, path: &str, start_time: &str, end_time: &str, format: &str, encode: &str, scale: &str) {
start_convert_action(window, command, path, start_time, end_time, format, encode, scale);
}
- 把網頁端傳來的參數轉換成ffmpeg指令就完工了…
// src-tauri/src/utility/_command.rs
use std::{
str,
io::Error,
collections::HashMap,
process::{Command, Stdio}
};
use crate::library:: {
_process::{command_combine},
_path::{full_path_maker},
};
pub fn ffmpeg_command_maker(command: &str, path: &str, start_time: &str, end_time: &str, format: &str, encode: &str, scale: &str) -> Result<(Command, String), Error> {
let ffmpeg_cmd = ffmpeg_code_maker(command, path, start_time, end_time, format, encode, scale)?;
let mut command = command_combine(ffmpeg_cmd.as_str());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
Ok((command, ffmpeg_cmd))
}
fn ffmpeg_code_maker(command: &str, path: &str, start_time: &str, end_time: &str, format: &str, encode: &str, scale: &str) -> Result<String, Error> {
let codec = encode_codec(encode).unwrap_or("-c copy");
let output_path = full_path_maker(path, format)?;
let video_scale = if scale.is_empty() {
String::new()
} else {
format!("-vf scale='{}'", scale)
};
let ffmpeg_cmd = format!("{} -ss {} -to {} -i '{}' {} {} '{}'", command, start_time, end_time, path, codec, video_scale, output_path.display());
Ok(ffmpeg_cmd)
}
fn encode_codec(encode: &str) -> Option<&'static str> {
let encodes: HashMap<&str, &str> = [
("copy", "-c copy"),
("h264", "-c:v libx264 -pix_fmt yuv420p -c:a aac"),
("h265", "-c:v libx265 -pix_fmt yuv420p -tag:v hvc1 -c:a aac"),
].into_iter().collect();
encodes.get(encode).copied()
}
- 最後再把scrollbar隱藏就很像APP了…
- 名字改一改,圖示換一換,完工…
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>影片格式轉換器</title>
<style>
html, body { overflow: hidden; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
範例程式碼下載 - 影片轉換器 / JSON處理器
後記
完稿正好碰上小七生日,經過這幾次使用Rust
才知道,它之所以難學,倒不是因為語法,而是它檢查太嚴謹了,光要程式碼補全
就要猜得老半天,加上平時用有GC / ARC做記憶體回收的我,完全不知道為什麼變數會不見,真的是難倒我了啊,不過有AI的幫助之下,現在學習新語言入門真的是很方便又快速,以後市面上應該就會變成有經驗的RD + AI的組合了吧?