🍃 はじめに
ローカルPCや自宅サーバーに溜まった自炊漫画(ZIP/CBZ/PDF)や動画を、スマホやタブレットのブラウザからサクサク閲覧できるセルフホスト型メディアサーバー 「Tanuyomi(たぬヨミ)」 を個人開発しています。
このアプリでは、数千冊の漫画書庫のサムネイル生成や、高解像度ページ画像の軽量配信(WebP変換)に FFmpeg(libwebp) をフル活用しています。
開発を進める中で、画像変換まわりで 2つの非常に面白い技術的課題 にぶつかりました。
- 【高速化編】 画像を1枚ずつFFmpegでWebP化すると「1枚あたり44.4ms」かかっていたのを、1プロセス複数入出力+ストリーム先頭判定で11倍速(3.9ms/枚)に高速化した手法
-
【バグ調査編】 Windows版では完璧に動くのに、Ubuntu 24.04 LTS(
apt install ffmpeg= FFmpeg 6.1.1)環境でだけ、標準出力パイプ(pipe:1)経由のWebP画像がブラウザで壊れて表示できなくなる怪現象のバイナリ解析と解決策
特に後半の「FFmpeg 6.1 の webp マルチプレクサが、32KBを超えるパイプ出力時だけストリーム末尾に4バイトのゴミを書き足す」という罠は、プログラムからFFmpegをパイプ呼び出しする人なら言語を問わずハマるポイントだと思います。
実測データとC#の実装コードを交えて共有します!
⚡ 第1部:【高速化編】FFmpegの画像変換を11倍速(44.4ms → 3.9ms/枚)にする技術
漫画ビューワーやメディアサーバーでは、1つのZIP書庫に200ページ、ライブラリ全体で数十万枚の画像が存在します。これらをリサイズしてWebPサムネイルにする処理を愚直に書くと、すぐにボトルネックになります。
1.1 「1枚1プロセス起動」の罠:エンコード本体は4ms、プロセス起動は40ms
最初期の実装では、画像を1枚変換するたびに ffmpeg プロセスを1回起動していました。
1200×1700pxのJPEG画像20枚を、横幅240pxのWebPサムネイルに変換する時間を計測した結果がこちらです。
| 方式 | 20枚の合計時間 | 1枚あたり平均 | 速度比 |
|---|---|---|---|
| 1枚ごとにFFmpegを起動(20回起動) | 888 ms | 44.4 ms / 枚 | 1.0x |
| 1回のFFmpeg起動で20枚を一括変換 | 78 ms | 3.9 ms / 枚 | 11.4x |
なんと、1枚44.4msのうち約40ms(9割以上)はOSのプロセス生成とFFmpegの初期化コストであり、画像デコード+リサイズ+WebPエンコード自体は 1枚あたり4ms弱 しかかかっていなかったのです。
1.2 -map を使った「1プロセス・N入力・N出力」コマンドの組み立て
FFmpegは、1回のコマンド実行で複数の入力(-i)を受け取り、-map <入力番号>:v でそれぞれの出力ファイルへ振り分けることができます。
# 1回の起動でN枚の画像をそれぞれリサイズしてWebP出力するコマンド構造
ffmpeg -hide_banner -loglevel error -y -threads 1 -filter_threads 1 \
-i src_0000.bin -i src_0001.bin ... -i src_0019.bin \
-map 0:v -vf "scale=..." -c:v libwebp -quality 75 -preset photo -vframes 1 out_0000.webp \
-map 1:v -vf "scale=..." -c:v libwebp -quality 75 -preset photo -vframes 1 out_0001.webp \
...
C# 側では以下のように引数を組み立てて一括実行しています。
private static readonly string[] CommonFfmpegOptions =
["-hide_banner", "-loglevel", "error", "-y", "-threads", "1", "-filter_threads", "1"];
private static string BuildScaleFilter(int width)
{
// WebPの最大辺制限(16,383px)を超えないよう安全にアスペクト比を維持して縮小
const int maxDimension = 16383;
return $"scale=w='max(1,min(iw,min({width},iw*{maxDimension}/ih)))':h='max(1,round(ih*ow/iw))'";
}
1.3 一括処理で必ず踏む「2つの落とし穴」と対策
単に20枚まとめるだけだと、実運用で2つの問題が起きます。
落とし穴①:並列ジョブ × FFmpeg内部スレッドによる「スレッド爆発」
バックグラウンドで論理コア数(例:16コア)ぶんのワーカーを並列に回している状態で、FFmpegがデフォルトのまま内部スレッドを立てると、16プロセス × 16スレッド = 256スレッド が立ち上がり、コンテキストスイッチだけでCPUが飽和します。
1枚の画像のリサイズとWebPエンコードは並列化の恩恵がほぼ無いため、-threads 1 -filter_threads 1 を明示してFFmpegを1スレッドに固定し、並列度はアプリ側の負荷調停ガバナー(BackgroundLoadGovernor)で一元管理するのが鉄則です。
落とし穴②:20枚中1枚でも破損画像があるとバッチ全体が巻き添えになる
古い書庫には、まれにヘッダーが壊れたJPEGなどが混ざっています。
バッチ実行で1枚でもエラーになると、FFmpegが途中で異常終了して残りの画像が出力されません。
そこで、「まず一括実行して生成できたファイルだけ回収し、欠けたインデックスだけを1枚ずつフォールバック実行する」 二段構えにしました。
// 1. まずは1プロセスで一括エンコードを試みる
var (outputPaths, runResult) = await RunBatchAsync(workDirectory, sourceImages, width, q, ffmpegPath, ct);
for (var i = 0; i < outputPaths.Count; i++)
{
results[i] = ReadOutputIfPresent(outputPaths[i]); // 作れたものだけ回収
}
// 2. 壊れた画像のせいで未生成になったものだけ、1枚ずつ作り直す(道連れ防止)
for (var i = 0; i < results.Length; i++)
{
if (results[i] != null) continue;
results[i] = await EncodeImageToWebpThumbnailAsync(sourceImages[i], width, q, ct);
}
これにより、正常な書庫は11倍速で駆け抜け、破損画像が混じる書庫でも確実に正常なページだけを救出できます。
1.4 単発ストリーム変換のゼロディスクI/O化(PrefixStream)
一方、ユーザーがビューワーで今まさに開いた1ページを即座にWebP変換したいときは、一時ファイルすら作らず 標準入力(pipe:0)→ FFmpeg → 標準出力(pipe:1) の完全オンメモリ・パイプラインで処理します。
ただし、入力画像が AVIF / HEIC(ISO Base Media File Format: ftyp ボックス) の場合、コンテナ構造上シークが必要になることがあり、pipe:0 ではデコードに失敗する場合があります。
しかもZIP書庫のエントリストリーム(DeflateStream)は CanSeek == false のため、先頭バイトを覗き見するとストリーム位置を巻き戻せません。
そこで、先頭16バイトだけを読んでマジックナンバー(bytes[4..8] == "ftyp")を判定し、読んだ16バイトと後続ストリームを継ぎ目なく結合する軽量ラッパー PrefixStream を挟むことで、余計なメモリコピーなしに「パイプ直流し」と「一時ファイル経由」を自動分岐させています。
🐛 第2部:【バグ調査編】Ubuntu 24(FFmpeg 6.1)でパイプ出力したWebPが壊れる謎
上記のパイプ出力(-i pipe:0 ... pipe:1)は、Windows版(FFmpeg 8.0 / 9.0)では完璧に動作していました。
ところが、Linux版を Ubuntu 24.04 LTS で動かしたユーザー環境で、「書庫を開いても画像が壊れて表示されない(ブラウザがデコードエラーを起こす)」 という現象が発生しました。
Ubuntu 24.04 の apt install ffmpeg で入るバージョンは FFmpeg 6.1.1 です。
2.1 第一の罠:pipe:1 に出力すると RIFF ファイルサイズが 0 のままになる
まず出力されたWebPバイナリの先頭20バイト(RIFFヘッダー)をダンプして調べました。
WebPファイルの先頭構造(RIFFコンテナ)は以下のようになっています。
Offset Size Description
0..3 4B "RIFF" (0x52 0x49 0x46 0x46)
4..7 4B ファイル全体長 - 8 (uint32 little-endian) <-- ★ここ!
8..11 4B "WEBP" (0x57 0x45 0x42 0x50)
12..15 4B チャンク型 ("VP8 " または "VP8L" または "VP8X")
16..19 4B チャンクペイロード長 (uint32 little-endian)
20.. NB VP8 / VP8L 画像データ本体
FFmpegで出力フォーマットに -f webp pipe:1 を指定すると、FFmpeg内部のWebPマルチプレクサ(libavformat/webpenc.c)が動きます。
webpenc.c はアニメーションWebPにも対応するため、以下のような動きをします。
- フレーム出力時(
flush)に、とりあえず先頭のファイルサイズ欄(Offset4..7)を0x00000000(サイズ未定) に書き換えて出力する。 - 全フレームを書き終えた終了処理(
webp_write_trailer)で、先頭4バイト目にシーク(avio_seek(s->pb, 4, SEEK_SET))して本当のファイルサイズを上書きする。
ところが、出力先がファイルではなく 標準出力パイプ(pipe:1) の場合、ストリームはシークできません(ESPIPE)。
その結果、Offset 4..7 が 0 のまま出力されてしまいます!
「じゃあ C# 側で bytes.Length - 8 を書き込めば直るのでは?」→ まだ直らなかった!
最初は「なるほど、C#側で受け取った byte[] の4〜7バイト目に bytes.Length - 8 を書き込んで修復(Sanitize)すれば解決だ!」と考え、修復処理を入れました。
240pxの小さなサムネイル(数KB〜20KB)はこれで表示できるようになりました。
しかし、マンガビューワーの本命である読書用画像(横幅1,600px、約60KB〜300KB)が、依然としてUbuntu 24で一切表示されなかったのです。
2.2 真犯人の特定:32KBを超えるとストリーム末尾に「4バイトのゴミ」が付く!
「なぜ小さいサムネイル(10KB)は直るのに、大きい画像(100KB)だけ壊れるのか?」
FFmpeg 6.1 の libavformat/webpenc.c の webp_write_trailer() のソースコードを追いかけて、衝撃の事実が判明しました。
// FFmpeg 6.1 libavformat/webpenc.c (webp_write_trailer の抜粋)
filesize = avio_tell(s->pb);
avio_seek(s->pb, 4, SEEK_SET); // (1) 先頭4バイト目へシークしようとする
avio_wl32(s->pb, filesize - 8); // (2) ★シーク成否を確認せずに4バイト書き込む!
avio_seek(s->pb, filesize, SEEK_SET);
ここが最大の罠でした。
-
画像が 32KB(
AVIOContextの内部バッファ長 32,768 bytes)以下の場合:
まだパイプへフラッシュされずメモリバッファ内に全データが残っているため、avio_seek(s->pb, 4, SEEK_SET)がバッファ内で成功することがあります。 -
画像が 32KB を超える場合(例:本来のサイズが 100,000 バイト):
先頭データはすでにpipe:1へフラッシュ済みのため、(1) のavio_seek(s->pb, 4, SEEK_SET)は 失敗 し、ストリームの書き込み位置は 末尾(100,000バイト目)のまま になります。
それにもかかわらず、(2) のavio_wl32(s->pb, filesize - 8)がエラー戻り値を見ずに実行されるため、WebPストリームの一番最後(100,000〜100,003バイト目)に 4バイトのサイズ値が余分に追記されてしまう のです!
このとき、pipe:1 から受け取った 100,004 バイトの配列はどうなっているでしょうか?
| バイト位置 | 内容 | 状態 |
|---|---|---|
0..3 |
"RIFF" |
正常 |
4..7 |
0x00000000 |
不正(0のままフラッシュされた) |
8..11 |
"WEBP" |
正常 |
12..15 |
"VP8 "(または "VP8L") |
正常 |
16..19 |
99,980(本来のVP8データ長) |
正常(libwebp が書いた正確な値!) |
20..99,999 |
VP8 画像ペイロード(99,980バイト) | 正常 |
100,000..100,003 |
99,992(webp_write_trailer が末尾に漏らした4バイト) |
💀 末尾ゴミデータ |
ここに単純な「RIFFサイズ = bytes.Length - 8(99,996)」の修復をかけると何が起きるか?
- ブラウザのWebPデコーダ(Chromium等の
libwebp)が画像を読む。 - Offset
16..19のVP8チャンクサイズは99,980なので、20 + 99,980 = 100,000バイト目で1つ目のチャンクの読み取りが完了する。 - ところが、外側の
RIFFヘッダーには「全体で 100,004 バイトある(残り4バイトある)」と書かれている! - デコーダは「後ろにまだ別のRIFFチャンク(アルファチャンネルやEXIF等)が続くはずだ」と判断し、100,000バイト目から次のチャンクヘッダー(最低8バイト必要)を読もうとする。
- 残り4バイトしか無いため「不正なチャンク構造(Truncated / Invalid WebP)」と判定し、画像全体をデコードエラーとして破棄する!
これが「Ubuntu 24(FFmpeg 6.1)で大きいWebP画像だけが表示できない」現象の完全なメカニズムでした。
🛠️ 第3部:解決策 — 3段構えの根本対策
原因が1バイト単位で特定できたので、以下の3つの対策を実装しました。
対策1:静止画WebPのパイプ出力には -f webp ではなく -f image2pipe を使う
そもそも libwebp エンコーダ自体は、最初から正しい RIFF サイズと VP8 チャンクサイズが入った完璧なパケットを生成しています。それを壊していたのは -f webp で呼ばれるマルチプレクサ(webpenc.c)でした。
単一フレームの静止画WebPを pipe:1 に出力する場合は、マルチプレクサに -f image2pipe を指定します。
image2pipe はエンコーダ(libwebp)が出力したパケットに一切手を加えずそのまま標準出力へ流すため、古いFFmpeg 6.1であっても先頭ゼロ化も末尾4バイトゴミも一切発生しなくなります。
var args = new List<string>(CommonFfmpegOptions)
{
"-i", "pipe:0",
"-vf", BuildScaleFilter(width),
"-c:v", "libwebp",
"-compression_level", "4",
"-quality", quality.ToString(CultureInfo.InvariantCulture),
"-preset", "photo",
"-vframes", "1",
- "-f", "webp",
+ "-f", "image2pipe",
"pipe:1"
};
対策2:SanitizeWebpRiffHeader で末尾余剰バイトの切り詰め(Truncate)を行う
さらにフェイルセーフとして、万が一 -f webp 経由のストリームや破損データが渡ってきた場合でも完全に修復できるよう、VP8 / VP8L の正確なチャンクサイズ(Offset 16..19)を基準にして末尾のゴミを切り詰めるロジックを実装しました。
💡 実装のポイント: RIFFフォーマットの仕様上、チャンクサイズが 奇数バイト の場合は末尾に
0x00のパディングが1バイト付く(2バイト境界アライメント)ため、本来の全体長は20 + chunkSize + (chunkSize & 1)で計算します。
internal static bool SanitizeWebpRiffHeader(ref byte[] bytes, ILogger? logger = null)
{
if (bytes == null || bytes.Length < 12) return false;
// 先頭が "RIFF" かつ 8〜11 バイト目が "WEBP" であることを確認
if (bytes[0] != 0x52 || bytes[1] != 0x49 || bytes[2] != 0x46 || bytes[3] != 0x46 ||
bytes[8] != 0x57 || bytes[9] != 0x45 || bytes[10] != 0x42 || bytes[11] != 0x50)
{
return false;
}
var modified = false;
if (bytes.Length >= 20)
{
var isVp8 = bytes[12] == 0x56 && bytes[13] == 0x50 && bytes[14] == 0x38 && bytes[15] == 0x20; // "VP8 "
var isVp8L = bytes[12] == 0x56 && bytes[13] == 0x50 && bytes[14] == 0x38 && bytes[15] == 0x4C; // "VP8L"
if (isVp8 || isVp8L)
{
var currentChunkSize = (uint)(bytes[16] | (bytes[17] << 8) | (bytes[18] << 16) | (bytes[19] << 24));
var maxPossibleChunkSize = (uint)(bytes.Length - 20);
if (currentChunkSize == 0 || currentChunkSize > maxPossibleChunkSize)
{
// チャンクサイズ自体が壊れている場合は全体長から逆算して修復
bytes[16] = (byte)(maxPossibleChunkSize & 0xFF);
bytes[17] = (byte)((maxPossibleChunkSize >> 8) & 0xFF);
bytes[18] = (byte)((maxPossibleChunkSize >> 16) & 0xFF);
bytes[19] = (byte)((maxPossibleChunkSize >> 24) & 0xFF);
modified = true;
}
else
{
// RIFF チャンクは奇数バイト長の場合 1 バイトのパディング(0x00)が続く仕様
var expectedTotalLength = 20u + currentChunkSize + (currentChunkSize & 1u);
if ((uint)bytes.Length > expectedTotalLength)
{
// FFmpeg 6.1 等がシーク失敗時に末尾へ書き足した 4 バイトのゴミを切り詰める
Array.Resize(ref bytes, (int)expectedTotalLength);
modified = true;
}
}
}
}
// 最後に正しい全体長から RIFF サイズ (全体長 - 8) を書き込む
var expectedRiffSize = (uint)(bytes.Length - 8);
var currentRiffSize = (uint)(bytes[4] | (bytes[5] << 8) | (bytes[6] << 16) | (bytes[7] << 24));
if (currentRiffSize != expectedRiffSize)
{
bytes[4] = (byte)(expectedRiffSize & 0xFF);
bytes[5] = (byte)((expectedRiffSize >> 8) & 0xFF);
bytes[6] = (byte)((expectedRiffSize >> 16) & 0xFF);
bytes[7] = (byte)((expectedRiffSize >> 24) & 0xFF);
modified = true;
}
return modified;
}
対策3:Linux版にも検証済みのFFmpeg(LGPL共有ビルド)を同梱する
さらに根本的な話として、Linuxのディストリビューションごとに apt で入るFFmpegのバージョンがバラバラ(Ubuntu 22.04は4.4、Ubuntu 24.04は6.1、Snap版FFmpegは /tmp がサンドボックス隔離されていて一時ファイルが読めない等)という問題がありました。
そこで、Windows版だけでなく Linux版(linux-x64 / linux-arm64)にも BtbN/FFmpeg-Builds の LGPL 共有ライブラリビルド(FFmpeg 9.0系)を同梱 する構成に刷新しました。
-
tools/ffmpeg/<RID>/にffmpeg,ffprobeとlibav*.so.*7本を配置。 - Linuxの動的リンカ(
ld.so)はカレントディレクトリの.soを自動探索しないため、ELFのDT_RUNPATHを$ORIGIN:../libに設定したうえで、C#のプロセス起動ヘルパー側でも子プロセスのLD_LIBRARY_PATHに同梱ディレクトリを自動注入。 - これで、Linuxでも
tar.gzを展開して./tanuyomiを叩くだけ(apt install ffmpegすら不要) で、どのOSでも100%同じエンコーダ・同じ挙動で動くようになりました。
🍃 おわりに:この技術が詰まった「Tanuyomi(たぬヨミ)」を公開しています!
今回紹介した「FFmpeg 11倍速バッチ変換」と「ストリームパイプライン」は、個人開発しているセルフホスト型メディアサーバー 「Tanuyomi(たぬヨミ)」 のコアエンジンとして稼働しています。
-
Dockerもランタイム導入も一切不要:ZIP(Linuxは
tar.gz)を解凍して実行ファイルを起動するだけ - ZIP書庫・PDFを解凍せず0.1秒でストリーミング閲覧:ブラウザだけで見開き表示・ルーペに対応
- 動画の100コマ追従シークバー:マウスを乗せるだけで目的のシーンが瞬時に見つかる
- ファイラーモード搭載:ブラウザ上からクリックひとつで「ZIP書庫内の画像をまとめてWebP化して容量を劇的削減」「PDFから画像書庫を抽出」「動画のH.265/AV1変換」が可能
Windows版(win-x64)および Linux版(linux-x64 / linux-arm64)を無償公開していますので、自炊漫画や動画ライブラリの管理にお困りの方はぜひ触ってみてください!
- 🔗 公式サイト / ダウンロード:
https://nullponta.github.io/tanuyomi-website/
この記事が、FFmpegをプログラムから呼び出して画像・動画処理を行う開発者の方の参考になれば幸いです。
よければ いいね / ストック していただけると開発の励みになります!