カテゴリー: FrontEnd

フロントエンドで動画デコレーション&レンダリング

はじめに

今回は、以下のように動画用の背景画像をHTML+CSSで作り、その中心に動画を合成してレンダリングしてみたいと思います。今回主に紹介したいのは、「DOMのキャプチャ」と「ffmpeg.wasm」を使ったレンダリングです。

レンダリング方法の検討

次の2つの方法を考えました。

  1. 背景用のDOMをキャプチャし、ffmpegで動画と合成する
  2. 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で導入します。

pnpm add modern-screenshot

DOMのキャプチャ

modern-screenshotの domToPng()を使ってキャプチャします。この関数はPromiseを返すのですが、

"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で、先ほどキャプチャした画像と動画を合成していきます。

"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のログに出力されます。

 ⨯ 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することで、サーバサイドレンダリングを回避してみます。

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 }を指定することで、サーバサイドレンダリングをしないようにできます。このようにすることで、先ほどのエラーを回避することができます。

さいごに

フロントエンドだけでなんとか動画の合成とエンコードに成功しました!

おすすめ書籍

カイザー

シェア
執筆者:
カイザー
タグ: Next.js

最近の投稿

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

1か月 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前

FSMを使った状態管理をGoで実装する

はじめに 一般的なアプリケーシ…

3か月 前