はじめに
今回は、以下のように動画用の背景画像をHTML+CSSで作り、その中心に動画を合成してレンダリングしてみたいと思います。 今回主に紹介したいのは、「DOMのキャプチャ」と「ffmpeg.wasm」を使ったレンダリングです。
レンダリング方法の検討
次の2つの方法を考えました。
- 背景用のDOMをキャプチャし、ffmpegで動画と合成する
- DOM上で背景とvideoタグを配置した上で、を丸ごと全フレームを画像に変換し、ffmpegまたはWebCodecsで動画にエンコードする
今回は1の方法で実装を進めます。2もやってみたのですが、次のような理由で実装が難しく、断念しました。
- キャプチャ対象のDOMにvideoタグが含まれると、iOSでキャプチャできない。そのため、別途Remotion Player外にvideoタグを用意し、キャプチャするときだけRemotion Player内のVideoコンポーネントの代わりにcanvasを置き、外のvideoタグの内容をcanvasに書き写す必要がある。
- Playerを1フレーム進めてから、実際に動画のフレームが描画されるまでラグがあり、videoの
onSeeked
でキャプチャする必要があるが、それを1フレームずつ繰り返していると途中で止まってしまう。
ちなみに、1の方法だと、動画の最中で背景に変化をさせることができません。
背景部分のキャプチャ
まずは、背景部分をキャプチャします。
modern-screenshotの導入
DOMをキャプチャするパッケージはいくつかありますが、その中でも最も様々なDOMやデバイスに対応していると思われる「modern-screenshot」を使います。このハッケージはhtml-to-imageのフォークです。(ちなみに、html-to-imageもdom-to-imageのフォークですが、dom-to-imageはメンテナンスが止まっています。)
これらのパッケージの動作原理ですが、こちらが参考になります。基本的には、
<svg>
タグ内にHTMLを埋め込むことができる
<foreignObject>
の中にHTMLを入れ、svgをPNGに変換することで画像化しています。
それでは、moden-screenshotをpnpmで導入します。
1 | pnpm add modern-screenshot |
DOMのキャプチャ
modern-screenshotの
domToPng()
を使ってキャプチャします。この関数はPromiseを返すのですが、
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | "use client"; import { useCallback, useMemo, useRef, useState } from "react"; import { domToCanvas, domToPng } from "modern-screenshot"; export default function MVShortenerMain() { const [videoFile, setVideoFile] = useState<File | null>(null); const bgRef = useRef<HTMLDivElement>(null); const onClickRender = useCallback(() => { if (!bgRef.current) { return; } // scale=4は仮の値。本来は、playerのサイズから出力先の1080x1920に合わせて計算する domToPng(bgRef.current, { backgroundColor: "transparent", scale: 4 }) .then(async (dataUrl) => { await render(dataUrl); // ffmpegでレンダリング }) .catch((err) => { console.log(err); }); return; }, [render]); return ( <> <div ref={bgRef} style={{ width: 270, height: 480, backgroundImage: "linear-gradient(151deg, rgba(247, 93, 139, 1), rgba(254, 220, 64, 1))", fontSize: 50, textAlign: "center", paddingTop: 30, }} > 🎄🕺🍖 </div> <Button onClick={onClickRender}>動画を作る</Button> </> ); } |
これで、DOMをキャプチャすることができました。
ffmpeg.wasmで画像と動画を合成
ffmpegでエンコード
最後に、ffmpeg.wasmで、先ほどキャプチャした画像と動画を合成していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | "use client"; import { FFmpeg } from "@ffmpeg/ffmpeg"; import { fetchFile, toBlobURL } from "@ffmpeg/util"; export default function MVShortenerMain() { const [videoFile, setVideoFile] = useState<File | null>(null); const ffmpegRef = useRef(new FFmpeg()); const loadIfNeeded = async () => { // ビルド済みのffmpegをダウンロードして読み込む const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd"; const ffmpeg = ffmpegRef.current; if (ffmpeg.loaded) { return; } // ffmpegのログをコンソールに出力 ffmpeg.on("log", ({ message }) => { console.log(message); }); // toBlobURL is used to bypass CORS issue, urls with the same // domain can be used directly. await ffmpeg.load({ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"), wasmURL: await toBlobURL( `${baseURL}/ffmpeg-core.wasm`, "application/wasm", ), }); }; const render = useCallback( async (bgUrl: string) => { if (!videoFile) { return; } await loadIfNeeded(); const ffmpeg = ffmpegRef.current; // 動画をファイルシステムに書き込み await ffmpeg.writeFile("input", await fetchFile(videoFile)); // 先ほどキャプチャした画像を書き込み await ffmpeg.writeFile("bg.png", await fetchFile(bgUrl)); await ffmpeg.exec([ "-i", // ファイルシステムに書き込んだ画像をinput "bg.png", "-i", // ファイルシステムに書き込んだ動画をinput "input", "-filter_complex", // 動画を中心にフィットするように配置し、その上に画像をオーバーレイ "[0:v]scale=1080:1920[bg];[1:v]scale=w=trunc(ih*dar/2)*2:h=trunc(ih/2)*2, setsar=1/1, scale=w=1080:h=1920:force_original_aspect_ratio=1, pad=w=1080:h=1920:x=(ow-iw)/2:y=(oh-ih)/2:color=black@0;[bg]overlay=(W-w)/2:(H-h)/2", "-preset", // エンコード速度を最速にする "ultrafast", "-t", "5", "output.mp4", // outputのファイル名 ]); const data = (await ffmpeg.readFile("output.mp4")) as any; console.log(data); const a = document.createElement("a"); a.href = URL.createObjectURL( new Blob([data.buffer], { type: "video/mp4" }), ); a.download = "output.mp4"; a.click(); }, [videoFile], ); // ・・・省略・・・ ); } |
-filter_complexでは、背景画像の上に、動画を中心にフィットするように配置しています。。動画をフィットして配置するコマンドは以下を参考にしました。
http://sogohiroaki.sblo.jp/article/183618558.html
また
-preset ultrafast
にすることで、エンコード速度を最速にしています。これは、ffmpeg.wasmはトランスコード(動画や音声などののデコード・エンコード)のパフォーマンスが悪く、presetを指定しないとエンコードにめちゃくちゃ時間が時間がかかってしまうためです。
presetのみを指定すると、速度を速くすればするほどファイルサイズが大きくなります(ビットレート一定モードと併用すると、時間をかけた分品質が向上します)。このオプションの詳細は以下が参考になります。
https://tech.ckme.co.jp/ffmpeg_h264.shtml
これで実際に動かしてみると、以下のような動画が出力されました!
Next.jsでの動的import
ここまでの実装で、一通り動くのですが、Next.jsを使っていると以下のエラーがNext.jsのログに出力されます。
1 2 3 | ⨯ node_modules/.pnpm/@ffmpeg+ffmpeg@0.12.10/node_modules/@ffmpeg/ffmpeg/dist/esm/empty.mjs (4:0) @ new FFmpeg ⨯ Error: ffmpeg.wasm does not support nodejs at MVShortenerMain (./src/app/mv-shortener/MVShortenerMain.tsx:45:69) |
Node.jsではffmpeg.wasmは使えないようなので、どうやらサーバサイドレンダリング時にffmpeg.wasmを使っているのが良くないようです。そこで、ffmpegを使っているコンポーネントそのものをdynamic importすることで、サーバサイドレンダリングを回避してみます。
1 2 3 4 5 6 7 8 9 10 | import dynamic from "next/dynamic"; const MVShortenerMain = dynamic( () => import("@/app/mv-shortener/MVShortenerMain"), { ssr: false }, ); export default function MVShortenerPage() { return <MVShortenerMain />; } |
dynamic()
の第2引数に
{ ssr: false }
を指定することで、サーバサイドレンダリングをしないようにできます。このようにすることで、先ほどのエラーを回避することができます。
さいごに
フロントエンドだけでなんとか動画の合成とエンコードに成功しました!