6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rust + FFmpeg + OpenCV】ターミナルをメディアプレイヤーにする

6
Last updated at Posted at 2025-07-06

1. 概要

ターミナル上でアニメーションをASCIIアートに変換してリアルタイム再生するソフトウェア ascii-term をRustで作りました。Bad Apple!! を知っている方には「あれだね」となるかもしれません。

bad_apple.gif

cookie_bomb_rush.gif

smooooch.gif

yuruyuri.gif

他にもGIFを gallery/ に保管していますので、気になる方はリポジトリを覗いてください。
スターを押してもらえると元気が出ます ⭐

技術スタック

役割 ライブラリ
動画・音声デコード ffmpeg-next
画像フィルタ処理 opencv
ターミナル制御 crossterm
非同期ランタイム tokio
スレッド間通信 crossbeam-channel
音声出力 rodio
画像処理・リサイズ image / fast_image_resize
CLI引数解析 clap

2. 技術選定

既存ツールとの違い

ターミナル上でアニメーションを再生するアプリは既知のものであり、すでに存在します。ただ、それらの多くが Bad Apple!! を再生するためだけのいわば専用機であり、拡張できるものが少ないです。

Rustで同様のものとして tplay があり、ascii-term はある意味二番煎じとなります。ただ、ascii-term との相違点は 責任分離を意識した設計 にあります。デコード処理をライブラリとして切り出すことで、将来的な高度な動画編集機能の開発ができるようにしています。

なぜRustなのか

CやC++ではなくRustを選んだ理由は、スレッドセーフでありメモリ安全、かつFFmpegやOpenCVが提供するエラーをカスタムエラー型に統一して安全にハンドリングできるからです。

動画編集機能の開発は初めての試みでしたので、原因のわからない場所でデバッグしたくなかった、というのが正直なところです。CやC++だとエラー原因の特定が面倒で、Rustのコンパイラがあればその多くを事前に防げます。

3. プロジェクト構造

ascii-term は Cargo ワークスペース で管理された3クレート構成です。

ascii-term/
├── Cargo.toml              ← ワークスペース定義・共通依存バージョン管理
└── app/
    ├── codec/              ← 動画・音声デコード・画像加工API
    ├── downloader/         ← ファイルダウンローダー
    └── ascii-term/         ← ターミナルメディアプレイヤー(バイナリ)

codec(動画・音声デコード・画像加工API)

このプロジェクトを作った個人的な動機は、将来的に動画編集ソフトを作りたいという思いがあったからです。そのため、メディア処理はライブラリとして切り出して ascii-term はクライアントとして使うという設計にしています。

内部では ffmpeg-next を使ってフレーム解析、デコーダー・エンコーダー、リアルタイム処理パイプラインをより抽象的に使いやすく設計しています。さらに、OpenCVを使って画像のフィルタ処理も提供しています。

codec/src/
├── media.rs         ← MediaFile, MediaInfo(メタデータ)
├── pipeline.rs      ← デコードパイプライン(フレームバッファリング)
├── video/
│   ├── decoder.rs   ← VideoDecoder(ffmpeg-nextラッパー)
│   └── frame.rs     ← VideoFrame(RGB変換・リサイズ)
├── audio/
│   ├── decoder.rs   ← AudioDecoder(ffmpeg-nextラッパー)
│   └── frame.rs     ← AudioFrame(サンプル正規化)
└── image/           ← 静止画処理(OpenCV連携)

ascii-term(ターミナルメディアプレイヤー)

UIを提供するバイナリクレートで、ascii-term の顔です。動画・音声・ターミナル表示の3つの処理を並行させています。

ascii-term/src/
├── main.rs          ← エントリポイント・CLI引数解析(clap)
├── player.rs        ← 再生制御・A/V同期ロジック
├── terminal.rs      ← ターミナル表示・キー入力処理
├── renderer.rs      ← ASCIIレンダリング(画像 → ASCII変換)
├── audio.rs         ← 音声デコード・rodioへの出力
└── char_maps.rs     ← ASCII文字マップ定義

downloader(ファイルダウンローダー)

URLから動画を取得するライブラリです。YouTube系URLは yt-dlp 経由、それ以外は reqwest で直接ダウンロードする設計にしています。現在も開発中のため、詳細な説明は割愛します。

4. クレートを分けた理由

3クレートに分割した背景には、いくつかの理由があります。

コンパイル時間の短縮

Rustのインクリメンタルコンパイルはクレート単位で行われます。たとえばレンダリングのロジックを修正したとき、codec は再コンパイルされません。変更の局所性がそのまま待ち時間に直結します。

依存関係の一方向化

クレート間の依存は ascii-term → codec の一方向のみです。ターミナルの描画コードがFFmpegの詳細を知る必要はなく、デコードコードがターミナルのエスケープシーケンスを知る必要もありません。このような分離がないと、機能追加のたびに無関係なコードが絡みやすくなります。

テスタビリティ

codec クレートはターミナルや音声出力に依存しないため、バイナリなしで単体テストが書けます。デコードの変換処理やASCII変換などのテストはこの形式で独立して検証できます。

将来の動画編集ソフトへの布石

最も大きな動機はここです。codec を独立させることで、将来的には動画編集ソフトの基盤として育てていく予定です。ascii-term はその「動いているサンプル実装」という位置づけでもあります。

5. デコードパイプライン

ffmpeg-nextによるデコード

ffmpeg-next はC言語で書かれたFFmpegのRustラッパークレートです。提供されるAPIは低レベルであるため、動画ファイルを入力したら即座に再生できるものではありません。コンテキストの初期化・ストリーム選択・フォーマット変換まで、スクラッチで組み上げる必要があります。

codec クレートはこの低レベルAPIをまとめ上げ、フレーム解析・デコード・変換を一貫して扱えるAPIとして提供しています。

フレームバッファリング

動画の再生はフレームのデコードが継続的に行われる必要があります。デコードが遅いと再生が詰まるため、Pipeline がフレームをあらかじめバッファリングします。

fn decode_and_buffer_frames(&mut self) -> Result<()> {
    while self.frame_buffer.len() < self.config.buffer_size && !self.is_eof {
        match self.decoder.decode_one() {
            Ok(Some(frame)) => self.frame_buffer.push_back(frame),
            Ok(None)        => { self.is_eof = true; break; }
            Err(e)          => return Err(e),
        }
    }
    Ok(())
}

6. ASCIIレンダリング

基本原理

ASCIIアートの根幹は輝度値(0〜255)を視覚的な密度が異なる文字に変換することです。

  • 暗いピクセル → 空白(
  • 明るいピクセル → 密度の高い文字(@# など)

情報があるようで意外とない領域です。ASCIIアートを作ったことがある人でないと知らないことですね。

輝度の計算

輝度の計算式はITU-R BT.709(人間の目の感度特性)に基づいています。

let luminance = (0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32) as u8;
//                 ^赤        ^緑(最も明るく感じる)    ^青(最も暗く感じる)

緑が最も明るく感じられ(0.7152)、青が最も暗く感じられる(0.0722)という人間の知覚特性を反映しています。

文字マップ

pub fn luminance_to_char(luminance: u8, char_map: &str) -> char {
    let chars: Vec<char> = char_map.chars().collect();
    if chars.is_empty() { return ' '; }
    let index = (luminance as usize * chars.len()) / 256;
    chars[index.min(chars.len() - 1)]
}

数学的には単純です。

  1. 入力:輝度値(0-255)
  2. 正規化luminance * chars.len() / 256
  3. インデックス計算:文字配列の適切な位置を選択
  4. 境界チェック:配列外アクセスを防止

文字マップは複数用意しています。

pub const CHARS_BASIC:    &str = " .:-=+*#%@";    // Bad Apple!! でよく見るやつ
pub const CHARS_BLOCKS:   &str = " ░▒▓█";          // ブロック文字
pub const CHARS_BRAILLE:  &str = " ⠁⠃⠇⠏⠟⠿⣿";      // 点字文字
pub const CHARS_GRADIENT: &str = " ▁▂▃▄▅▆▇█";      // グラデーション(冒頭GIFで使用)

グラデーション文字セットは冒頭のGIFで使用しているもので、明度と表現力が秀でています。ただ、これがいいと感じるなら、そもそもASCIIアートに変換する必要がなくなってしまいますが、ロマンです。UNIXの産物を使い倒すのです。

文字マップはコンパイル時定数として保持しているため、実行時の文字列解析コストがありません。

pub const CHAR_MAPS: &[&str] = &[...];      // コンパイル時定数
pub const CHAR_MAP_NAMES: &[&str] = &[...];

カラーレンダリングとターミナル描画

各ピクセルのRGB値をそのまま crossterm の Color::Rgb に渡すことで、ターミナル上でカラー表示を実現しています。

描画は1行ごとに MoveTo(0, y) でカーソルを先頭に戻してから書き出す方式です。これにより、ターミナルの列幅と描画幅が一致しない場合でも「右下に向かって斜めにずれる」問題が発生しません。

crossterm のスタイル付き文字(ch.stylize().with(color))はエスケープシーケンスを含むため、表示幅と文字列長が一致しません。フラットな文字列を print! するだけだとこのズレが積み重なって画像全体が歪みます。MoveTo で行頭に戻すことでこの問題を完全に回避できます。

for y in 0..height {
    execute!(out, MoveTo(0, y as u16))?;
    let mut row_string = String::with_capacity(width * 20);

    for (j, ch) in chars[row_start..row_end].iter().enumerate() {
        let rgb_index = (row_start + j) * 3;
        let r = frame.rgb_data[rgb_index];
        let g = frame.rgb_data[rgb_index + 1];
        let b = frame.rgb_data[rgb_index + 2];
        let color = Color::Rgb { r, g, b };
        row_string.push_str(&format!("{}", ch.stylize().with(color)));
    }
    write!(out, "{}", row_string)?;
}

7. 映像・音声同期(A/V Sync)

最も難しかったのが映像と音声を同期させることです。単純にフレームカウントで制御すると、レンダリングのコストが一定でないため徐々にずれが生じます。

スレッド構成

ターミナル上で動画と音声を別スレッドで再生し、双方の同期を取りつつ、ユーザーからの入力を受信するスレッドの合計3つで構成しています。

PTSベースの同期

映像フレームにはPTS(Presentation Timestamp)と呼ばれる「コンテナ上での実際の再生時刻」が記録されています。これを使って「壁時計上のどの時刻にこのフレームを表示すべきか」を計算します。

let offset = pts_offset.unwrap_or(Duration::ZERO);
let frame_pts = frame.timestamp.saturating_sub(offset);
let elapsed = playback_start_time.elapsed();

if elapsed >= frame_pts {
    // 描画
} else {
    let wait = frame_pts - elapsed;
    time::sleep(wait.min(Duration::from_millis(5))).await;
}

pts_offset は最初のフレームのPTSを記録したもので、0以外から始まるファイルに対応するためのオフセットです。

フレームスキップによる追従

レンダリングに時間がかかると映像が音声に比べて遅れていきます。2フレーム分以上遅れているフレームはスキップして次のフレームに進むことで、音声への追従を維持します。

let lag = elapsed.saturating_sub(frame_pts);

if lag <= frame_duration * 2 {
    let rendered_frame = self.renderer.render_video_frame(&frame)?;
    self.frame_tx.send(rendered_frame)?;
}
// else: 無言でスキップ。次のフレームをすぐ取得する
frame_count += 1;

フレームスキップは「映像の滑らかさが落ちる」というトレードオフがありますが、音声との同期を維持する上では避けられない仕組みです。

音声デコードと出力

音声は別スレッドでデコードして crossbeam-channel 経由でリアルタイムに rodio の Sink へ送り込んでいます。

DirectAudioSourcerodio::Source を実装した構造体で、チャンネルからサンプルを受け取り Iterator<Item = f32> として rodio に提供します。デコードが追いつかないときは500msタイムアウトで待機し、バッファアンダーランを穏やかに処理します。

8. ターミナルサイズへの対応

起動時のサイズ自動検出

以前はレンダリング解像度を 80x24 にハードコードしていたため、ターミナルが大きくても映像が小さなエリアにしか描画されませんでした。現在は起動時に crossterm::terminal::size() で実際のターミナルサイズを取得してレンダリング解像度を設定しています。

let (term_width, term_height) = crossterm::terminal::size().unwrap_or((80, 24));
let render_config = RenderConfig {
    target_width: (term_width as u32).saturating_div(config.width_modifier.max(1)),
    target_height: term_height as u32,
    // ...
};

リサイズは解像度固定で対応

再生中にターミナルをリサイズするとレンダリングコストが変化して A/V sync が崩れることが分かりました。そのため再生中のリサイズは解像度を変更せず、画面クリアと最終フレームの再描画だけを行う仕様にしています。

Event::Resize(_, _) => {
    // 解像度は起動時に固定。画面クリアと最終フレームの再描画のみ
    self.clear_screen()?;
    if let Some(ref frame) = self.last_frame.clone() {
        self.display_frame(frame)?;
    }
}

9. キー操作

キー 動作
Space 一時停止 / 再開
Q / Esc 終了
M ミュート切り替え
G グレースケールモード切り替え
C 文字マップの切り替え
? ヘルプ表示

10. ハマったポイント

映像と音声のずれ

最初はフレームカウントと sleep を組み合わせたタイマーで制御していましたが、レンダリングのコストがフレームごとに変動するため徐々に音声から遅れていきました。

解決策は FFmpeg が各フレームに付与している PTS(Presentation Timestamp) を直接使って「この時刻になったら描画する」という壁時計ベースの制御に切り替えることでした。フレームカウントに依存した制御からPTSに依存した制御にするだけで、ズレが大幅に改善されました。

ターミナル描画の斜め歪み

crossterm でスタイル(色)付きの文字を print! すると、エスケープシーケンスが文字列に含まれるため、実際の表示幅とコードの文字列長がズレます。このズレが行ごとに積み重なると、動画全体が右下に向かって斜めに歪んで見える問題が発生しました。

1行ごとに MoveTo(0, y) でカーソルを行頭に強制移動することで、どれだけエスケープシーケンスが含まれていても正確に描画位置をリセットできます。

ffmpeg-nextの低レベルAPI

ffmpeg-next はFFmpegのラッパーとはいえ、低レベルAPIを提供しているため、コンテキストの初期化・ストリーム選択・フォーマット変換(YUV→RGB)・EOFのフラッシュ処理など、決まり文句のようなボイラープレートが多く必要です。

codec クレートでこれらをまとめ上げることで、ascii-term 側からは pipeline.next_frame() を呼ぶだけでフレームが取得できるようになっています。

11. 今後の展望

  • シーク対応:任意の時刻に飛べる機能。コマンドラインのメディアプレイヤーとしては欲しい
  • downloader の整備:URLから直接動画をダウンロードして再生する機能
  • codec の拡張:将来の動画編集ソフトに向けて、エンコード・フィルタパイプラインをさらに整備していく

全ての処理を詳らかに説明するには骨折りであり、私も次の開発を楽しみたいため、気になった方は実際にリポジトリを覗いていただき、スターを付けてもらえると嬉しいです!

まとめ

  • 3クレートのワークスペース構成で、デコードAPIとUIを分離して設計している
  • 将来の動画編集ソフト開発のために codec をライブラリとして切り出した
  • PTSベースのタイミング制御とフレームスキップにより A/V syncの問題を解消 した
  • MoveTo による行ごとレンダリングでターミナル描画の歪み問題を解消 した
  • crossterm::terminal::size() で起動時にターミナルサイズを自動検出し、全画面で映像を描画する
6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?