【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
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的有意為之吧?
  • 這裡要監聽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))
}
  • 可以試試看,看看是不是可以輸出檔案到外部了?

讀取進度

  • 接下來就把輸出資訊改成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()的方式來處理,這樣我們就把資訊飛鴿傳書傳給前端了…
  • 這裡先設定兩個事件,分別是errorfinish
/// 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();
});
  • 而在畫面方面呢,就加一個叫logBoxdiv,用來顯示訊息…
  • 當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來分隔訊息,就是returnenter
  • 至於為什麼會有\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_cellMutex來處理它的唯一性…
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>
  • 再增加轉檔的時間區域設定選項…
  • 這裡要注意的是refreactive差異性ref是用來存儲單個值的,而reactive是用來存儲對象或數組的…
/// 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的組合了吧?