はじめに
動画から音声だけを抜き出してmp3に変換するWebサービス「ConvertMP4MP3」を個人開発でリリースしました。この手のツールは検索するとすでに山ほどありますが、作ってみて分かったことや、実際にハマったポイントが色々あったので、記録として残しておきます。
「またmp4→mp3変換サービスか」と思われるかもしれませんが、今回は以下のような点にこだわりました。
- URLを入力するだけで変換できる形式は、あえて採用しませんでした。動画共有サイトのURLを入力させる形式は著作権侵害のリスクが高く、自分が権利を持つファイルのみをアップロードしてもらう形にしています
- サーバースペックを「なんとなく」ではなく、実際に負荷テストをして数値で決めることにしました
- 単なる音声抽出だけでなく、無音トリミング・音量正規化・ID3タグ付与を自動で行うようにしました
技術構成
構成はシンプルです。
- フロントエンド: Next.js(App Router)
- APIサーバー: Node.js + Express
- ジョブキュー: BullMQ + Redis
- 変換処理: FFmpeg(fluent-ffmpeg経由)
- インフラ: さくらのVPS + Nginx + Let's Encrypt
アップロード → APIサーバーがジョブをキューに登録 → 別プロセスのワーカーがFFmpegで変換 → 完了後にダウンロードリンクを発行、という一般的な非同期処理の構成です。API側で重い変換処理を直接行わないようにすることで、リクエスト受付とファイル変換の負荷を分離しています。
サーバー選定を「勘」ではなく実測で決めた
VPSのスペックを決める際、最初は「同時アクセス数」も「ファイルサイズの上限」も何も決まっていませんでした。そこで、いきなり本番用のVPSを契約する前に、検証用の小さいVPSでベンチマークを取ることにしました。
/usr/bin/time -vを使って、動画の長さ別に処理時間とメモリ使用量を計測したところ、次のような結果になりました(3コア/メモリ2GBのVPSでの計測)。
| 動画の長さ | 処理時間(単独実行) | ピークメモリ |
|---|---|---|
| 1分 | 0.34秒 | 約57MB |
| 5分 | 1.78秒 | 約58MB |
| 30分 | 8.90秒 | 約62MB |
音声抽出のみ(動画のエンコードを伴わない)処理は非常に軽量で、動画の長さにほぼ比例して処理時間が伸びることが分かりました。ここから、
予想処理時間(秒) ? 動画の長さ(分) × 0.33
という簡単な予測式を導き出せました。さらに同時実行数を5件・10件と増やしていったところ、3コアのVPSで「同時実行数 ÷ コア数」にほぼ比例してCPU使用率が下がっていく、素直な劣化パターンであることも確認できました。
同時5件: CPU使用率 約65%(理論値 3÷5=60%とほぼ一致)
同時10件: CPU使用率 約30%(理論値 3÷10=30%とほぼ一致)
急激な性能崩壊(スラッシング)が起きなかったことから、「メモリ不足で破綻する」のではなく「単純にCPUを分け合っているだけ」と判断でき、同時実行数の上限を8?10件、タイムアウトを「予測時間の3倍+最低30秒」という設計に落とし込めました。数値の裏付けがあると、この手の設計判断にかなり自信が持てます。
ハマったポイント集
1. Accept-Languageヘッダーの罠
多言語対応(日本語・英語・中国語)をNext.jsのミドルウェアで実装した際、最初はこう書いていました。
function detectLocale(acceptLanguageHeader) {
const header = (acceptLanguageHeader || '').toLowerCase();
if (header.includes('zh')) return 'zh';
if (header.includes('en')) return 'en';
if (header.includes('ja')) return 'ja';
return DEFAULT_LOCALE;
}
一見動きそうに見えますが、日本語ユーザーの多くが/enに飛ばされるという不具合が発生しました。原因は、日本語環境のブラウザでもAccept-Languageヘッダーには次点言語としてenが含まれていることが多いためです。
ja,en-US;q=0.9,en;q=0.8
上記のヘッダーは「第一希望は日本語」という意味ですが、.includes('en')はen-USにもマッチしてしまい、しかもenのチェックがjaより先に書かれていたため誤判定していました。修正としては、q値をちゃんとパースして優先順位を尊重する必要があります。
function detectLocale(acceptLanguageHeader) {
if (!acceptLanguageHeader) return DEFAULT_LOCALE;
const entries = acceptLanguageHeader
.split(',')
.map((part) => {
const [tag, qPart] = part.trim().split(';q=');
const q = qPart ? parseFloat(qPart) : 1;
return { tag: tag.toLowerCase(), q: Number.isNaN(q) ? 1 : q };
})
.sort((a, b) => b.q - a.q);
for (const { tag } of entries) {
const primary = tag.split('-')[0]; // "en-US" → "en"
if (SUPPORTED_LOCALES.includes(primary)) return primary;
}
return DEFAULT_LOCALE;
}
単純な文字列一致で「多言語判定っぽいもの」を書くと、こういう落とし穴にハマりやすいという教訓でした。
2. CAPTCHAウィジェットが一瞬表示されて消える
Cloudflare Turnstile(CAPTCHA)を導入した際、ページ読み込み直後にウィジェットが一瞬表示されて、すぐに消えるという不可解な現象に遭遇しました。ブラウザのコンソールを見ると、こんなエラーが出ていました。
Warning: Did not expect server HTML to contain a <div> in <div>.
Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.
原因は、Turnstileがclass="cf-turnstile"を検知して自動的にDOMへウィジェット(iframe等)を注入する仕組みになっており、これがReactの「サーバー側で組み立てたHTML」と「クライアント側で組み立て直すHTML」の突き合わせ(ハイドレーション)と衝突していたことでした。Turnstileが横からDOMをいじるので、Reactが「想定と違う」と判断して、その部分のDOM を丸ごと作り直してしまい、結果的にウィジェットが消えていたわけです。
対処法は、自動描画をやめて明示的にレンダリングする方式に切り替えることでした。
// スクリプトの読み込み時に自動描画させない
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" ... />
// useEffect内で、スクリプトの読み込みを待ってから明示的に描画する
useEffect(() => {
function tryRender() {
if (window.turnstile && divRef.current) {
window.turnstile.render(divRef.current, { sitekey, callback });
} else {
setTimeout(tryRender, 100);
}
}
tryRender();
}, []);
サードパーティのウィジェットスクリプトをReactに組み込む際は、「勝手にDOMをいじられていないか」を疑うのが大事だと学びました。
3. sudoが対話式ターミナルを要求してくる
VPSでの作業中、WinSCPのコンソール機能(1コマンドずつ実行する方式)からpm2 startupのセットアップコマンドを実行しようとしたところ、こんなエラーが出ました。
sudo: a terminal is required to read the password; either use the -S option
sudoがパスワードを対話的に聞こうとしても、非対話式の実行環境ではそれができずに失敗する、というものです。-Sオプションで標準入力からパスワードを渡す形にすることで解決しました。
echo 'パスワード' | sudo -S コマンド
4. 「無音トリミング」フィルタが、動画の途中で処理を打ち切っていた
音量正規化・無音トリミングを追加した後、24分ほどの動画を変換すると、22?25秒程度のmp3になってしまうという不具合が発生しました。厄介なのは、ffmpeg自体はエラーを一切出さず、正常終了として扱われていたことです。
原因はsilenceremoveフィルタのstop_periods(末尾の無音を除去するオプション)でした。ドキュメント上は「末尾の無音区間を削除する」とありますが、実際の挙動は**「条件に合う無音区間を最初に検出した時点で、それ以降の出力を丸ごと止める」**という、かなり癖のあるものでした。24分の動画のどこかに、たまたま条件に合う無音があれば、そこで問答無用で打ち切られてしまいます。
# 危険: 動画の途中に無音があると、そこで出力が止まる
silenceremove=...:stop_periods=1:stop_threshold=-50dB:stop_silence=0.1
stop_silence(無音とみなす継続時間)を0.1秒から3秒に伸ばしても症状は変わらず、根本的にこのオプションの使い方自体を諦めることにしました。対処法は、末尾側のオプションを丸ごと外し、先頭のトリミングのみに絞ることです。
# 安全: 先頭のみ。一度音が始まれば、そのあとは最後まで出力し続ける
silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.1
先頭のトリミングは「無音の間は出力せず、音が始まったら出力を開始する」という一方向の処理なので、「見つけたら止める」という危険な性質を持っていません。同じsilenceremoveというフィルタ名でも、start_側とstop_側でリスクの質が全く違うという、地味だが重要な教訓でした。
5. フィルタ追加で処理時間が伸び、タイムアウトの見積もりが崩れていた
上記の不具合を先頭トリミングのみに修正した後も、同じ24分の動画が今度は「変換に失敗しました」というエラーになるという、別の症状に遭遇しました。
原因は、以前の負荷テストで導き出した「動画の長さ(分) × 0.33秒」という予測式が、フィルタを一切かけない単純コピーの速度を基にしたものだったことです。loudnorm(音量正規化)を追加した状態で計測し直すと、24分の動画の処理に実時間で約73秒かかっていました(1分あたり約3秒、単純コピー時の約10倍)。当時のタイムアウト設定(最低60秒)を、実際の処理時間が上回ってしまい、SIGKILLで強制終了させられていたわけです。
// 修正前: フィルタなしの速度を基準にしていた
const ESTIMATE_SECONDS_PER_MINUTE = 0.33;
// 修正後: loudnorm込みで再計測した値に更新
const ESTIMATE_SECONDS_PER_MINUTE = 3.5; // 安全マージンを見て少し余裕を持たせた
**「処理内容を変えたら、それに紐づくタイムアウトや見積もりの前提も一緒に見直す」**というのは、書けば当たり前ですが、機能追加のたびに見落としがちなポイントだと痛感しました。
音声処理へのこだわり:ただ変換するだけで終わらせない
単純な音声抽出だけでは、既存の変換サービスと差別化しにくいと感じたため、FFmpegの機能だけで実現できる3つの後処理を追加しました。
const command = ffmpeg(inputPath)
.noVideo()
.audioCodec('libmp3lame')
.audioQuality(2)
.audioFilters([
'silenceremove=start_periods=1:start_threshold=-50dB:start_silence=0.1:stop_periods=1:stop_threshold=-50dB:stop_silence=0.1',
'loudnorm=I=-16:TP=-1.5:LRA=11',
])
.outputOptions(['-metadata', `title=${title}`])
.output(outputPath);
-
無音トリミング(
silenceremove): 動画の前後にありがちな無音区間を自動でカット -
音量正規化(
loudnorm、EBU R128準拠): 統合ラウドネス-16LUFSに自動調整。これは一般的なポッドキャスト配信などでも使われる基準値です - ID3タイトルタグ: 元のファイル名から自動生成し、カーナビ等での再生時に曲名が表示されるようにする
実際に使ってみると、この処理は発話中心のコンテンツ(会議録音・ポッドキャスト・ライブ音源の観客録画など)と特に相性が良いことが分かりました。逆にクラシック音楽のような、静と動のコントラスト自体が表現になっているジャンルには不向きです。「なんでも変換できる万能ツール」ではなく、「この用途に強いツール」という打ち出し方のほうが、機能の実態に誠実だと感じています。
監視・運用まわり
個人開発でも最低限の運用体制は用意しました。
-
構造化ログ(pino): JSON形式でログを出力し、
pm2 logsでそのまま追える形に - ログローテーション(pm2-logrotate): ログの肥大化を自動で防止
- 異常検知: プロセス停止・ディスク逼迫・APIダウンを5分おきにチェックし、ntfy.sh経由で通知。アカウント登録不要で使える点が個人開発と相性が良かったです
おわりに
実測に基づく設計判断や、地味だけど再現性のあるバグ(Accept-Languageの罠、Turnstileのハイドレーション衝突など)は、他の個人開発の場面でも十分に活きる知見だと思います。
現在は日本語・英語・中国語の3言語に対応し、FAQ・対応フォーマット解説・カーナビでの再生ガイドなどのコンテンツも整えています。今後は複数ファイルの一括変換や、広告・アフィリエイトによる運営コストの補填なども検討中です。
質問やフィードバックがあれば、ぜひコメントで教えてください。