Next.jsとFFmpeg.wasmで安心安全な動画変換サービスを作った話
Next.jsとFFmpeg.wasmで安心安全な動画変換サービスを作った話
はじめに
皆さん、こんにちは。この記事をご覧いただきありがとうございます。今回は、Next.js(App Router、バージョン15)とFFmpeg.wasmを使用したWebアプリ「Henvate - ヘンベート」についてお話したいと思います!
Henvateとは?
Henvateは、動画と音声を変換するアプリで、以下のことができます。
- 違うファイル形式への変換
- 動画を圧縮
- 画質を簡単に変更(1920x1080の動画を、640x360にしたりできます。)
なぜ作ろうと思ったのか。
個人的な都合によりファイルサイズが大きい動画を圧縮する必要があったのですが、信頼できるサーバー以外にファイルは送信したくありませんでした。(アップロードに時間もかかるので...)
ということで、自分で作っちゃおうということになりました。(ついでにWebアプリとして公開しよう!)
さあ、作ろう
ここから本題の「作った話」をしていきます!
サイト構成
- Next.js(App Router、バージョン15)を使用した多言語対応の静的サイト。(SSG)
- 動画の変換にはFFmpeg.wasm(バージョン0.12.10)を使用。
- Tailwind CSSではなくPanda CSSを使用。
- UIライブラリにはPark-UIとShadow Pandaを使用。
- サイトのデプロイにはCloudflare Pagesを使用。
まあ普通の(特殊ではない)構成かと思います。
Next.jsで多言語対応させるのに...
初めはnext-intlで適切な言語ページにリダイレクトさせるために、SSR(Edge Runtime)で動作させていたのですが、どうもFFmpeg.wasmと相性が良くないらしく以下のエラー(2つ)が発生していました。
TypeError: Cannot read properties of undefined (reading 'length')
at Reflect.get (<anonymous>)
at Array.forEach (<anonymous>)
at Array.forEach (<anonymous>) {
digest: '3792911742'
}
-----------------
Error [ReferenceError]: document is not defined
at <unknown> (.next\server\edge-runtime-webpack.js:668)
at <unknown> (.next\server\edge-runtime-webpack.js:668:36)
at <unknown> (.next\server\edge-runtime-webpack.js:1203:13)
at <unknown> (.next\server\edge-runtime-webpack.js:1209:12)
自分の技術不足かもしれませんが、next-intlの使用は諦めて、SSRからSSGに変更したところこのエラーは改善しました。
自力で多言語対応させる。
next-intlや、その他のライブラリを使用するとまたエラーが発生しそうだったので自力で作成することにしました。結果以下のようになりました。(ファイルの命名については気にしないでください。)
src
├─app
│ ├─en-GB ...
│ │
│ └─ja-JP ...
│
├─contexts
│ Locale.context.tsx
│
└─hooks
Locale.hook.tsx
Locale.context.tsx
にはプロバイダーが定義されていて、Locale.hook.tsx
は現在のロケールを取得するためのフックが定義されています。各コンポーネントの多言語対応は以下のようにしています。
import { useLocale } from '@/hooks/Locale.hook';
const TRANSLATIONS = {
'ja-JP': {
contents: '翻訳!'
},
'en-GB': {
contents: 'Translation!'
}
};
export default function Sample() {
const { locale } = useLocale(), // localeには'ja-JP'か'en-GB'の値が入ります。
TRANSLATION = TRANSLATIONS[locale];
return (
<p>{TRANSLATION.contents}</p>
)
}
【余談】翻訳はどのようにしているのか?
自分はそこまで英語が得意ではないので、基本DeepLとChatGPTを使用して翻訳しています。
利用規約やプライバシーポリシーなど、長文はChatGPTに翻訳させています。
本当にAIや翻訳機はすごいなと心から実感します。
今度はFFmpeg.wasmが動かない!
多言語対応は何とか自力で出来ましたが、今度は一番重要なFFmpeg.wasmが以下のエラーで正常に動作しませんでした。
hook.js:608 ./node_modules/.pnpm/@ffmpeg+ffmpeg@0.12.15/node_modules/@ffmpeg/ffmpeg/dist/esm/classes.js:105:28
Module not found: Can't resolve <dynamic>
103 | if (!this.#worker) {
104 | this.#worker = classWorkerURL ?
> 105 | new Worker(new URL(classWorkerURL, import.meta.url), {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
106 | type: "module",
107 | }) :
108 | // We need to duplicated the code here to enable webpack
以下が問題のコードです。(本来のコードから少し変更しています。)
async function initializeFFmpeg(onProgress?: (message: string) => void): Promise<void> {
const BASE_URL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.9/dist/umd';
try {
await this.ffmpeg.load({
coreURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.wasm`, 'application/wasm'),
workerURL: await toBlobURL(`${BASE_URL}/ffmpeg-core.worker.js`, 'text/javascript'),
});
} catch (error) {
throw error;
}
...
}
このエラーは、公式の実装例を参考に、完全にクライアント側でのみ動作するように実装し直したところ直りました。
デザイン・UIライブラリについて
Tailwind CSS?じゃなくてPanda CSS!!
今までNext.jsでWebサイトを構築する際は、Tailwind CSSを使用していましたが、「ゼロランタイムCSS-in-JSを使ってみよう!」ということでPanda CSSを採用しました。
Shadow Panda & Park UIと出会う
PandaCSSを採用したことで、shadcn/uiが使えなくなりました。(Tailwind CSSを使うので)
そこで、Panda CSSを使用するshadcn/uiをベースとしたUIライブラリ「Shadow Panda」を使用することにしました。
また、同じくPanda CSSで動作するUIライブラリ「Park UI」も使用することにしました。
Shadow PandaとPark UIはどちらも有能
動画や音声をアップロードするコンポーネントはPark UI(File Upload)から、ナビゲーションに使用するメニュー(ホバーで展開)はShadow Pandaのもの(Navigation Menu)を使用しています。
File Upload(ファイル選択済み)
Navigation Menu
それぞれの足りないところをどちらか片方で補っていて、足りないものがありませんでした。ただ、少し問題が発生しました。
コンポーネント名が同じだから...
動画変換のページで、進捗を表示するためのプログレスバーを配置したところ、きちんとプログレスバーが表示されません!
想定していたもの(完成形)
原因としては、Park UIのProgressコンポーネントを使用していたのですが、なんとShadow PandaにもProgressコンポーネントがありました。同じ名前のためそれぞれのCSSが混在しているようでした。(これで表示が崩れていた。)
そこで、Shadow PandaのリポジトリからPanda CSS用のスタイルプリセットをクローンし、必要なもののみを残して、それをpanda.config.ts
でインポートする形式をとりました。
...
// ↓ 不必要なレシピ(スタイルが記述されたファイル)等を削除したものを配置したフォルダ
import shadowPandaPreset from './shadow-panda';
export default defineConfig({
preflight: true,
include: [...],
exclude: [],
theme: { ... },
presets: [
shadowPandaPreset as any,
...
],
jsxFramework: 'react',
outdir: 'styled-system',
});
【余談1】変換中のプレビューを作る!(動画変換のみ)
変換しているときに、プログレスバーと推定残り時間などしかないと良く進捗が分かりにくいなということで変換中に「今動画のどこを変換しているか。」が分かるようにプレビューを作ることにしました。
(他にもファイルサイズやFFmpegのログなどを表示してはいます。)
完成形(変換している動画はHIKAKIN&SEIKINさんの「コール」いい曲ですよね!)
作り方としては
- FFmpegのログから現在の動画時間を取得(
frame= 0 fps= 0 q=0 size= 0kB time=hh:mm:ss.ms bitrate= 0kbits/s speed=0x
のtime=
のところを抽出) - ファイルをBlob URLにする
- そのBlob URLを指定したVideo要素をコントロール無効で配置
- 1で取得した動画時間をVideo要素に指定
です。難しいことはしていません。
export function VideoPreview({ blobUrl, currentTime }: { blobUrl: string; currentTime: number; }) {
const videoRef = useRef<HTMLVideoElement>(null),
VIDEO_THROTTLE_DELAY = 1000,
throttledEffect = useRef(
throttle((currentTime) => {
if (videoRef.current) {
videoRef.current.currentTime = currentTime;
}
}, VIDEO_THROTTLE_DELAY),
);
useEffect(() => {
if (currentTime < 0) {
return;
}
throttledEffect.current(currentTime);
}, [currentTime]);
return (
<>
<video key={blobUrl} src={blobUrl} controls={false} className='...STYLE...' ref={videoRef}></video>
</>
);
}
【余談2】変換前と変換後の比較スライダーを作る!(動画変換のみ)
変換が終わった後、変換前と変換後で比較ができたらいいだろうなということで、作ることにしました。(正直なところ、これが一番大変でした。)
実装コードについてはかなり長いのでGitHub Gistで公開します。
- ChatGPT(Claude)に比較スライダーを作ってもらう(ベースとなるもの)
- いい感じにデザインを変更する
結果以下のようになりました。
動画は余談1と同様「コール」です。
【余談3】音声変換のときは?
音声を変換しているときはプレビューを表示しても意味がないので、完成したものを再生できるようにしました。
音声プレイヤー
主要なページを作ろう!
やっとここまで来ました。詳細なコードについては説明しませんが、FFmpegのオプションについてのお話ができればと思います。
動画変換でのFFmpegのオプション
現時点(2025/02/08)で以下のオプションを利用できるようにしています。
- 変換の種類:動画から音声なのか、動画から動画なのかについて選択
- 出力先の拡張子の選択(mp4,mkv,mp3,wav,ogg...)
- 動画コーデックの選択(libx264、libx265、vp9)
- FPSの指定(60,50,30...)
- 変換品質の選択(
-crf
オプションで、50,40,30...,1まで) - 変換プリセットの選択(
-preset
オプションで、利用できるオプション全て) - 動画ビットレートの選択(各解像度(1080p,4Kなど)に合わせたオプション)
- 動画ピクセルフォーマットの選択(
-pix_fmt
オプションで、yuv420p
とgray
が利用可能) - 音声コーデックの選択(aacなど)
- 音声ビットレートの選択(320kから48kまでのオプション)
- サンプリングレートの選択(48000から8000まで)
- 音声チャンネル数の選択(ステレオかモノラル)
設定選択モーダル
これから、さらに多くのオプションに対応したいと思っています。(ユーザーが入力できるようにも!)
動画の画質変更でのオプション
現時点(2025/02/08)で以下のオプションを利用できるようにしています。
- 動画の横幅(width):px単位で指定します。-1または入力を空にすると元の比率に合わせて計算されます。
- 動画の縦幅(height):px単位で指定します。-1または入力を空にすると元の比率に合わせて計算されます。
また、奇数値を指定されるとFFmpegがエラーを吐くため、奇数値を偶数値にするようにしています。
その他、変換品質に関係するオプションにも対応できたらと思っています。
オプション入力モーダル
動画の圧縮でのオプション
動画の圧縮では以下のオプションを一律で使用することにしています。
-c:v libx264 -c:a copy -crf 27 -tune zerolatency,fastdecode -preset veryfast -movflags +faststart -pix_fmt yuv420p
処理速度が速く、安定しているlibx264を使用し音声コーデックは変換しません。
また、変換品質(-crf
)は27に設定しています。これより低い値にすると、ファイルサイズが元のファイルよりも増加してしまうため、動画の品質とのバランスを考えた結果、27に落ち着きました。
-preset
については処理速度を考慮しveryfast
を指定しています。
音声変換でのオプション
現時点(2025/02/08)で以下のオプションを利用できるようにしています。
- 出力先の拡張子の選択(mp4,mkv,mp3,wav,ogg...)
- 音声コーデックの選択(aacなど)
- 音声ビットレートの選択(320kから48kまでのオプション)
- サンプリングレートの選択(48000から8000まで)
- 音声チャンネル数の選択(ステレオかモノラル)
設定選択モーダル
これから、さらに多くのオプションに対応したいと思っています。(ユーザーが入力できるようにも!)
(同じ文章動画変換でも見たな...)
音声の品質変更でのオプション
現時点(2025/02/08)で以下のオプションを利用できるようにしています。
- 音声ビットレート:kbps単位で指定します。最小は0で、最大は設定していません。(既定で128が指定されています。)
音声ビットレート以外のオプション(コーデックなど)にも対応していけたらと思います。(良いご意見がある場合は、ぜひコメントよろしくお願いいたします!)
オプション入力モーダル
音声の圧縮でのオプション
音声の圧縮では以下のオプションを一律で使用することにしています。
-c:a libmp3lame -b:a 128k -map 0:a
一律で汎用性の高いmp3ファイルに変換するので、コーデックはlibmp3lame
を、ビットレートは圧縮もできて品質もある程度確保できる128kにしています。
【おまけ】ユーザーからのフィードバックをどのように受け付けるのか。
ユーザーからのフィードバックは大切です!ということで、どのように受け付けるのかですが、Google フォームでアンケートを作成しそれをページに埋め込む形式を取ることで対応しました。
完成形(画面の途中まで)
おわりに
長い記事を最後までお読みいただき、誠にありがとうございました。ぜひ一度Henvateをご利用いただけると幸いです。
また、Next.jsとFFmpeg.wasmを使用してサイトを構築しようとしている方への参考になれば幸いです。
もしかすると、表現不足な箇所や、分かりにくい点があるかもしれませんが、その際はご指摘いただけると助かります。
ありがとうございました!
Discussion