這次的 rust-52-projects 系列帶來了 media-metadata-explorer——一個能深入剖析影音檔案內部結構的 CLI 工具。它透過 FFmpeg 的 libavformat 函式庫讀取媒體容器資訊,同時還做了一件很有趣的事:附帶了一個 libavformat-ffi companion crate,示範三種不同的 Rust FFI 呼叫方式(詳見之前的 FFI 比較文章)。
這篇文章會聚焦在工具本身的設計——三個實用的 CLI 子命令,以及用 crossterm 打造互動式 TUI 的過程。
專案功能概覽
media-metadata-explorer 提供三個子命令,對應三種使用情境:
inspect <file> [--json] 是最基本的單檔檢視模式。打開一個 .mkv 或 .mp3,它會列出容器格式名稱、總時長、檔案大小、位元率、容器層級的 metadata 標籤(如 title、encoder),以及每條串流的詳細資訊:影片串流會顯示解析度與幀率,音訊串流則顯示取樣率、聲道數、語言標籤等。加上 --json 旗標可以輸出結構化的 MediaReport,方便接到其他工具做二次處理。
catalog <dir> [--recursive] [--json] 是媒體庫整理利器。它會走訪目錄,篩選 mp4、mkv、mov、avi、webm、mp3、flac、wav 等常見副檔名,逐一探測後彙整成 CatalogReport:總時長、容器格式出現頻率(依次排序)、編解碼器統計,以及探測失敗的檔案清單。有個小細節:FFmpeg 回傳的格式名稱常常是 "mov,mp4,m4a,3gp,3g2,mj2" 這樣一串,程式只取第一個 token,讓統計結果更好讀。
tui <file> [--max-packets N] 則是整個專案最有趣的部分——互動式終端瀏覽器,下一節細說。
TUI 設計:用 crossterm 打造終端介面
tui 子命令會先讀取最多 N 個封包(預設 2000),在記憶體中建立一棵樹,再用 crossterm 啟動全螢幕互動介面。
樹狀結構分三個頂層節點:
- Container Info:格式名稱、時長、位元率、檔案大小、metadata 標籤
- Streams:每條串流展開後顯示編解碼器參數、串流標籤
- Packets:依串流索引分群,每個封包顯示 PTS、DTS、大小、是否為關鍵幀
導覽鍵支援 Vim 風格(j/k 上下、h/l 收合/展開)以及方向鍵、PageUp/PageDown、Home/End。Space 切換展開狀態,q 或 Esc 退出。顏色區分不同節點類型:cyan 是標題、magenta 是容器資訊、blue 是串流節點、green 是封包節點、yellow 是標籤值。
crossterm 的角色
crossterm 是 Rust 生態系裡最常見的跨平台終端控制函式庫,名字裡的 “cross” 就是這個意思——同一套 API 可以在 Windows、macOS、Linux 上跑,不用自己處理 ANSI escape code 或 Windows Console API 的差異。
它的 API 設計圍繞著「命令(command)」的概念,每個動作都是一個實作 Command trait 的型別:
use crossterm::{
cursor::{Hide, MoveTo, Show},
event::{self, Event, KeyCode},
style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor},
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};執行命令有兩個 macro:execute! 會立刻寫入並 flush,queue! 只是把命令放進緩衝區。TUI 的 render 函式全程用 queue! 積攢所有繪製動作,最後才呼叫一次 out.flush()——這樣終端只會看到一個完整畫面,不會因為部分更新而閃爍:
queue!(out, MoveTo(0, 0), Clear(ClearType::All))?;
// ... 積攢所有列的繪製命令 ...
out.flush()?; // 一次送出
進入 TUI 之前,程式需要做三件準備工作:啟用 raw mode(讓按鍵立刻送達、不等換行、不回顯到螢幕)、切換到 alternate screen(保留原本的終端內容,退出後可以還原)、以及隱藏游標。這三步統一在 TerminalGuard::enter() 裡完成,Drop 時反向還原:
fn enter(out: &mut Stdout) -> Result<Self> {
terminal::enable_raw_mode()?;
execute!(out, EnterAlternateScreen, Hide)?;
Ok(Self)
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = terminal::disable_raw_mode();
let _ = execute!(stdout(), Show, LeaveAlternateScreen);
}
}事件迴圈用 event::read() 阻塞等待下一個事件,Event::Key 處理按鍵,Event::Resize 處理視窗大小改變。Resize 事件不需要任何邏輯——只要設 dirty = true,下一個 frame 就會用新的 terminal::size() 重繪,自然就適應了新的寬高。
三層資料結構設計
TUI 的樹狀結構採用了一個乾淨的三層分離設計。
第一層:TreeNode(資料層)
struct TreeNode {
id: usize,
label: String,
children: Vec<TreeNode>,
}這是一棵遞迴的 owned tree,children 直接擁有子節點,不用指標也不用 Rc。每個節點的 id 由建構時的 &mut usize counter 遞增分配,全域唯一。這棵樹只管結構,完全不知道展開/收合狀態。
第二層:TreeState(狀態層)
struct TreeState {
selected: usize, // 游標在第幾行(flat index)
scroll: usize, // viewport 捲動偏移
expanded: BTreeSet<usize>, // 哪些 node_id 目前是展開的
}展開狀態完全分離在 BTreeSet<node_id> 裡,不碰 TreeNode 本身。要展開一個節點就 insert(id),要收合就 remove(id),操作極其簡單。
第三層:FlatLine(視圖層)
struct FlatLine {
node_id: usize,
label: String,
depth: usize,
has_children: bool,
expanded: bool,
}每次重繪前,flatten_tree() 遍歷 TreeNode、搭配 expanded set,產生「目前可見行」的扁平 Vec<FlatLine>。如果一個節點的 id 不在 expanded 裡,它的子節點就不會被加入——視覺上就「消失」了:
fn flatten_tree(node: &TreeNode, expanded_ids: &BTreeSet<usize>, depth: usize, out: &mut Vec<FlatLine>) {
let expanded = expanded_ids.contains(&node.id);
out.push(FlatLine { node_id: node.id, label: ..., depth, has_children: ..., expanded });
if has_children && expanded {
for child in &node.children {
flatten_tree(child, expanded_ids, depth + 1, out);
}
}
}這是典型的 document-view 分離:TreeNode 是不可變的文件結構,一次建立後不再修改;TreeState.expanded 是可變的互動狀態;Vec<FlatLine> 則是每個 render frame 重新推導的視圖,不需要維護反向指標。selected 和 scroll 追蹤的是 flat list 的 index,不是 node_id,因為鍵盤導覽只關心「螢幕上第幾行」。
實作上有幾個設計值得一提:
Dirty flag 避免不必要重繪。 TUI 的事件迴圈維護一個 dirty: bool,只有真正改變畫面狀態的按鍵(移動選取、展開/收合、視窗 resize)才設 dirty = true,下一個迴圈才重繪。這比每個 tick 無條件清屏再重畫要省 CPU,也避免畫面閃爍。
TerminalGuard 確保終端狀態復原。 進入 TUI 前需要切換到 alternate screen、啟用 raw mode、隱藏游標。為了確保不論正常退出還是 panic,終端都能恢復正常,程式定義了一個零大小的 TerminalGuard struct,在 Drop 裡執行清理動作(disable raw mode、show cursor、leave alternate screen)。這是 Rust RAII 慣用法的典型應用。
Parent 節點搜尋是 O(n) 的線性掃描。 當使用者按 h 要跳到父節點時,程式從當前行往上掃,找第一個 depth == current_depth - 1 的行。雖然理論上是 O(n),但實際上樹的深度有限、總行數也不多,這個簡單實作完全夠用,不需要額外維護父節點指標。
開發心得與踩坑記錄
BTreeMap 讓輸出穩定有序。 容器和串流的 metadata 標籤改用 BTreeMap<String, String> 而非 HashMap,好處是不論 JSON 輸出還是 TUI 顯示,標籤永遠按字母順序排列,不會因 hash 隨機性產生不穩定的輸出。
FFmpeg 的「不知道」值要小心處理。 FFmpeg 大量使用 0 或 -1 表示「未知/不可用」。程式定義了 to_u64(i64) -> Option<u64> 和 to_u32(i32) -> Option<u32> 兩個轉換幫手,把 0 和負值都變成 None,對外 API 就能乾淨地用 Option 表達可選欄位,不用每次都檢查魔法數值。
EOF 的位元運算。 FFmpeg 用負數的四字元碼表示 EOF 錯誤(AVERROR_EOF = -(('E' | 'O'<<8 | 'F'<<16 | ' '<<24)))。Rust 端重建這個常數的方式和 C 端一樣,不依賴外部匯入的常數,讓 EOF 和真正的讀取錯誤可以明確區分。第一次看到這個寫法還楞了一下,仔細想才明白這是 FFmpeg 的 FOURCC 慣例。
整體而言,這個專案把「實用工具」和「教學示範」結合得很好——media-metadata-explorer 本身就是個可以日常使用的媒體檔案檢視器,而 libavformat-ffi companion crate 則是學習 Rust FFI 的絕佳參考資料。