1
0

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】自作OSSクレート avio でターミナルメディアプレイヤーを作った話

1
Last updated at Posted at 2026-03-20

1. 概要

ターミナル上でアニメーションをASCIIアートに変換してリアルタイム再生するソフトウェア ascii-term を Rust で作りました。Bad Apple!! を知っている方にはあの感じ、と言えば伝わるかと思います。

また、こちらは以前の記事の後日談となります。
記事:【Rust + FFmpeg + OpenXC】ターミナルをメディアプレイヤーにする

bad_apple.gif

cookie_bomb_rush.gif

smooooch.gif

yuruyuri.gif

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

本記事の主題は ascii-term の紹介ではなく、その開発を通じて生まれた自作OSSクレート avio の紹介です。

ascii-term はもともと ffmpeg-next / ffmpeg-sys-next で動作していましたが、今回 codec クレートを全面的に avio に移行しました。本記事では以下を中心に解説します。

  • ascii-term のプロジェクト構成(avio がどこに位置するか)
  • ffmpeg-next から avio への移行経緯と API の比較
  • 移行を通じて見えた avio の良かった点・改善点
  • avio の将来ビジョン(動画編集ソフト・動画配信サービスへの活用)
  • ascii-term の主要実装(avio を使った部分にフォーカス)

ascii-term のプロジェクト設計・ASCIIレンダリングの仕組み・A/V sync の実装などの技術的な詳細については、以前に別記事で解説しています。本記事では avio に関わる部分を中心に取り上げます。

2. ascii-term のプロジェクト構成

avio がどのような立ち位置で使われているかを理解するために、まず ascii-term の構成を簡単に紹介します。

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

ascii-term/
├── Cargo.toml
└── app/
    ├── codec/       ← 動画・音声デコード・画像加工API ← ここが avio を使う
    ├── downloader/  ← ファイルダウンローダー
    └── ascii-term/  ← ターミナルメディアプレイヤー(バイナリ)

codec クレートは動画・音声のデコード、フレームの変換、リアルタイム処理パイプラインを提供するライブラリです。ascii-term はそのクライアントにすぎません。このクレートを独立させた理由は将来的に動画編集ソフトを作りたいという目的があるためです。ascii-term はその「動いているサンプル実装」という位置づけでもあります。

3. avio とは

avio は筆者が開発しているOSSのRustクレートです。FFmpegを内部で使いながら、低レベルAPIを隠蔽して動画・音声・画像の処理を高レベルなRust APIで提供することを目指しています。

ffmpeg-nextffmpeg-sys-next はFFmpegのRustバインディングとして広く使われていますが、どちらも本質的には低レベルAPIを提供するものです。avio はこれらの「使いにくさ」を解消するために作りました。

ターゲットとする開発者のユースケースは以下を想定しています。

  • 動画・音声を デコードして加工したい 開発者
  • 動画編集ソフトや変換ツールを Rust で書きたい 開発者
  • FFmpegの低レベルAPIを直接扱わずに 安全で読みやすいコードを書きたい 開発者

4. ffmpeg-next から avio への移行

ffmpeg-next の使いにくさ

ffmpeg-next は低レベルAPIを提供するラッパーであるため、動画1本をデコードするだけでも以下のような問題がありました。

  • デコードループを組むたびに決まり文句のようなボイラープレートが必要
  • send_packet / receive_frame の2ステップAPIが直感的でない
  • フォーマット変換(YUV→RGB)のために SwsContext を別途管理する必要がある
  • ffmpeg::init() の呼び出しが必要で、忘れると実行時エラーになる
  • ffmpeg-sys-next を通じた unsafe ブロックが随所に散在する

API 比較

ffmpeg-sys-next ffmpeg-next avio
抽象レベル 生のCバインディング 薄い安全ラッパー 高レベルRust API
unsafe の必要性 至る所で必要 一部 不要
初期化 ffmpeg::init() 必須 ffmpeg::init() 必須 不要
デコードAPI パケット手動管理 send_packet / receive_frame decode_one()
フォーマット変換 手動(SwsContext 手動(SwsContext ビルダーに組み込み
EOF検知 エラーコード判定 send_eof() + フラッシュループ Ok(None)
ビルダーパターン なし なし あり
学習コスト

Before / After のコード比較

ffmpeg-next(移行前)

// 忘れると実行時エラーになる初期化
ffmpeg::init()?;

// コンテキスト生成(複数ステップ必要)
let ictx = ffmpeg::format::input(&path)?;
let stream = ictx.streams().best(ffmpeg::media::Type::Video)
    .ok_or(anyhow::anyhow!("No video stream"))?;
let video_stream_index = stream.index();

let ctx = ffmpeg::codec::context::Context::from_parameters(stream.parameters())?;
let mut decoder = ctx.decoder().video()?;

// フォーマット変換のために別途 SwsContext が必要
let mut scaler = ffmpeg::software::scaling::context::Context::get(
    decoder.format(), decoder.width(), decoder.height(),
    ffmpeg::format::Pixel::RGB24,
    target_width, target_height,
    ffmpeg::software::scaling::flag::Flags::BILINEAR,
)?;

// デコードループ(2ステップ必要)
for (stream, packet) in ictx.packets() {
    if stream.index() == video_stream_index {
        decoder.send_packet(&packet)?;
        let mut frame = ffmpeg::frame::Video::empty();
        while decoder.receive_frame(&mut frame).is_ok() {
            let mut rgb_frame = ffmpeg::frame::Video::empty();
            scaler.run(&frame, &mut rgb_frame)?;
            // rgb_frame を処理...
        }
    }
}
// フラッシュも別途必要
decoder.send_eof()?;

avio(移行後)

// ビルダーパターンで1行で構築
let mut decoder = avio::VideoDecoder::open(path)
    .output_format(PixelFormat::Rgb24)  // フォーマット変換も内包
    .build()?;

// シンプルなデコードループ
loop {
    match decoder.decode_one() {
        Ok(Some(frame)) => { /* フレーム処理 */ }
        Ok(None)        => break,  // EOF
        Err(e)          => return Err(e),
    }
}

音声デコードも同様です。

let mut decoder = avio::AudioDecoder::open(path).build()?;

while let Ok(Some(frame)) = decoder.decode_one() {
    let samples = frame.samples_as_f32()?;
    sender.send(samples)?;
}

5. 移行して良かった点

実際に ascii-term という動くプロジェクトで avio を使ってみて、良かった点を整理します。

コードの読みやすさが劇的に改善された

移行前の codec クレートは ffmpeg-next の低レベルAPIを扱うために至る所にボイラープレートが散らばっていました。移行後は decode_one() に集約されたため、処理の意図がコードから直接読み取れるようになりました。

デコードループを読んで「次のフレームを取得する」とすぐに分かるコードと、send_packet / receive_frame / send_eof / フラッシュループを読んで「これはデコードしているのか」と考えながら読むコードでは、可読性が全く違います。

unsafe が一切不要になった

ffmpeg-sys-next 経由の操作では unsafe ブロックが必要でしたが、avio はすべて安全なRustで書かれているため unsafe は不要です。Rustの型システムとエラーハンドリングを最大限に活用できます。

初期化の手間がなくなった

ffmpeg::init() の呼び出しを忘れることによる実行時エラーが完全になくなりました。avio の初期化はデコーダーのビルド時に自動的に行われます。

ビルダーパターンで設定が宣言的になった

出力フォーマットの指定が .output_format(PixelFormat::Rgb24) のように宣言的に書けるようになりました。ffmpeg-next では SwsContext を手動で生成・管理する必要がありましたが、avio ではこれがビルダーに統合されています。

移行コストは低かった

codec クレートはもともと ffmpeg-next の低レベルAPIをラップするレイヤーとして設計していたため、外部インターフェースを変えずに内部実装を差し替えることができました。ascii-term 側のコードは一切変更不要でした。

悪かった点は特になく、移行のデメリットを感じる場面はありませんでした。強いて言えば avio が現時点でまだ機能が限定的であるため、高度な操作をしたい場面では ffmpeg-next を直接使う必要があるケースもあるかもしれません。しかし ascii-term の用途では全く問題ありませんでした。

6. 移行で見えた avio の改善点

実際の動画プレイヤーに投入したことで、APIの設計的な課題が具体的に見えてきました。これらは現在 avio の Issue として起票しています。

1. EOF通知の二重化

現在 Ok(None)Err(DecodeError::EndOfStream) の両方がEOFを表しており、呼び出し側でどちらも処理する必要があります。Ok(None) のみに統一することでユーザーのハンドリングがシンプルになります。

// 現状:2パターンのハンドリングが必要
match decoder.decode_one() {
    Ok(None) => break,                       // EOF パターン1
    Err(DecodeError::EndOfStream) => break,  // EOF パターン2
    // ...
}

// 理想:Ok(None) のみで表現
match decoder.decode_one() {
    Ok(None) => break,  // これだけでいい
    // ...
}

2. 音声フレームの正規化メソッドがない

音声データをf32に正規化する処理(packed/planar判定を含む)をユーザーが書く必要があります。as_f32_interleaved() のようなメソッドがあれば解決します。

3. シーク未対応

任意の時刻にジャンプするシークがまだ実装されていません。メディアプレイヤーや動画編集ソフトにとっては必須の機能です。

4. デコード時スケーリング未対応

output_size(width, height) のようなビルダーオプションがあれば、外部のリサイズクレートを使わずにデコード時に任意のサイズにスケーリングできます。ascii-term では現在 fast_image_resize を使って別途リサイズしています。

5. Iterator 未実装

decode_one() を使うことになりますが、for frame in &mut decoder のようなRustらしいイテレータAPIが使えると、コードがさらに簡潔になります。

7. avio の将来ビジョン

avio は ascii-term のためだけに作ったクレートではありません。動画編集ソフト・動画配信サービスの基盤として使えるレベルまで育てたいと考えています。

動画編集ソフトへの活用

私が ascii-term を作ったそもそもの動機は「動画編集ソフトを Rust で作りたい」という目的があったからです。avio は将来の動画編集ソフトのデコード・エンコード基盤として設計しています。

現状は codec クレートが avio をラップする形で ascii-term に提供していますが、最終的には avio 自体が gstreamer のような汎用メディアパイプラインクレートの競合として機能することを目指しています。

動画配信サービスへの活用

YouTubeのような動画配信サービスのバックエンドで必要になる処理——アップロード動画のトランスコード、サムネイル生成、メタデータ抽出——はいずれも avio が担えるユースケースです。

Rust で書かれた動画処理基盤は現状では選択肢が少ないため、avio がその空白を埋められると考えています。整備の方向性は以下を想定しています。

  • エンコードAPIVideoEncoder::new().output_format(...).build() でトランスコードが完結するAPI
  • フィルターパイプライン:複数の処理を宣言的につなぐ Pipeline::new().add_filter(...).encode(...) のようなAPI
  • 並列デコード:複数ストリームを並列処理するための非同期対応

8. ascii-term の主要実装(avio を使う部分)

ここでは ascii-term の実装の中でも、avio と直接関わる部分にフォーカスして解説します。

デコードパイプライン

Pipeline が avio のデコーダーをラップして、フレームをバッファリングします。

fn decode_and_buffer_frames(&mut self) -> Result<()> {
    let decoder = self.decoder.as_mut()
        .ok_or_else(|| MediaError::Pipeline("No decoder".to_string()))?;

    while self.frame_buffer.len() < self.config.buffer_size && !self.is_eof {
        match 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(())
}

PTSを使った映像・音声同期

avio の VideoFrame はフレームごとに PTS(コンテナ上の実際の再生時刻) を保持しています。ascii-term では音声を rodio でそのまま流しながら、映像フレームの PTS と壁時計を比較して「この時刻になったら表示する」という制御を行っています。

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 {
    let lag = elapsed.saturating_sub(frame_pts);
    if lag <= frame_duration * 2 {
        // 描画(2フレーム以上遅れていたらスキップ)
        let rendered_frame = self.renderer.render_video_frame(&frame)?;
        self.frame_tx.send(rendered_frame)?;
    }
} else {
    let wait = frame_pts - elapsed;
    time::sleep(wait.min(Duration::from_millis(5))).await;
}

frame.timestamp が avio によって Duration として直接提供される点がポイントです。ffmpeg-next では PTS をストリームのタイムベースで手動変換する必要がありましたが、avio では変換なしにそのまま壁時計と比較できます。

音声デコードとリアルタイム出力

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

DirectAudioSourcerodio::Source を実装した構造体で、チャンネルからサンプルを受け取り Iterator<Item = f32> として rodio に提供します。

9. 使用クレート

役割 クレート
メディアデコード avio(自作OSS)
ターミナル制御 crossterm
非同期ランタイム tokio
スレッド間通信 crossbeam-channel
音声出力 rodio
画像処理・リサイズ image / fast_image_resize
画像フィルタ処理 opencv
CLI引数解析 clap

10. まとめ

  • ffmpeg-next / ffmpeg-sys-next から自作OSSクレート avio に移行することで、デコードコードが大幅に簡潔になった
  • avio はビルダーパターン・decode_one() による統一API・unsafe 不要という設計で、ffmpeg-next より学習コストが低く読みやすい
  • 実際の動画プレイヤーに投入したことで、EOF通知の二重化・シーク未対応・Iterator未実装などの改善点が具体的に洗い出せた
  • 移行のデメリットは特になくcodec クレートの外部インターフェースを変えることなくスムーズに差し替えられた
  • avio は将来的に動画編集ソフト・動画配信サービスのバックエンドで使えるレベルの汎用メディアパイプラインクレートとして育てていく予定

気になった方はリポジトリを覗いてスターを付けていただけると励みになります!

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?