📽️

React で作るゆっくり解説 feat. Remotion

2023/01/10に公開約26,600字

React で動画が作れる方法をご存じでしょうか?
結論から見せるとこんなコードが

export const FirstVideoConfig: VideoConfig = {
  sections: [
    {
	title: 'イントロダクション',
	bgmSrc: '/audio/bgm/honobono-wartz.wav',
	backgroundVideo: '/video/cyber-bg.mp4',
	afterMovie: '/video/yukkuri-opening.mp4',
	talks: [
	  {
	    text: 'ねえねえ魔理沙',
	    speaker: 'reimu',
	    id: '59f8c2cd81334be5ab5cdc7899fad286',
	    audioDurationFrames: 25,
	  },
	  {
	    text: 'なんだ霊夢',
	    speaker: 'marisa',
	    id: '0ba332a465c3404a870de15cad021407',
	    audioDurationFrames: 22,		
	  },
       ]
    }
  ]
}

こんな動画になります。

https://www.youtube.com/watch?v=7L8Q06njTw4

これは Remotion という React で動画が作れるライブラリを用いて作っています。

動画でもざっくりとは語ったのですが、よりディープ目にこの記事ではやったことを書いていこうと思います。
プログラミングで動画を作ることの利点などが感じられる内容にはなっているかと思うので、これを機に Remotion トライしてみようという気持ちになれば幸いです。

それでは、

ゆっくりしていってね!

Remotion

この動画制作には Remotion という React で動画制作ができるライブラリを用いて作られています。

https://www.remotion.dev/

React で動画制作?????
と思われたかも知れません、私も最初はおもちゃかな?と思いました。

ですが、実際に Remotion で動画制作してみると嬉しいことがいろいろありました。

  • コンポーネントを定義できるので字幕やゆっくりの顔、音声と字幕の組み合わせなどが使い回しやすい
  • React に慣れている身としては学習コストが動画制作ツールを学ぶよりかなり低い
  • プログラミングで作っているので他の諸々(音声の生成とか)と接合しやすい
  • 人に話すとウケがいい

などなどです。
アニメーションのプリセットなどは他の動画制作ツールと比べて劣る部分があると思うので、非エンジニアの方におすすめできるかというと微妙ですが、「動画制作をしたことがない React を書いたことがあるエンジニア」には自信を持ってオススメできます。とりあえず私は制作体験最高でした。

ちなみにですが個人利用では商用利用可能です。法人利用では開発ライセンスの購入が必要になるようです。

ゴール

最終的に実現したかったゴールは1ファイル筋書きを書けばそれが動画になる体験でした。

冒頭にも書きましたが、具体的には次のような JS のオブジェクトに落ち着きました。

export const FirstVideoConfig: VideoConfig = {
  sections: [
    {
	title: 'イントロダクション',
	bgmSrc: '/audio/bgm/honobono-wartz.wav',
	backgroundVideo: '/video/cyber-bg.mp4',
	afterMovie: '/video/yukkuri-opening.mp4',
	talks: [
	  {
	    text: 'ねえねえ魔理沙',
	    speaker: 'reimu',
	    id: '59f8c2cd81334be5ab5cdc7899fad286',
	    audioDurationFrames: 25,
	  },
	  {
	    text: 'なんだ霊夢',
	    speaker: 'marisa',
	    id: '0ba332a465c3404a870de15cad021407',
	    audioDurationFrames: 22,		
	  },
       ]
    }
  ]
}

これを実現するために数々の試練を乗り越える必要がありました。
まずジャブのようなところから言うと

  • 字幕の表示
  • BGM / 動画の再生
  • ゆっくりの顔コンポーネントを作成

そしてより高度なところとして

  • オブジェクトから音声ファイルを作成する
  • 連続して再生するための Sequence の作成
  • 口パクや瞬き

あたりを頑張りました。
以下に紹介していくのでこれを読めばきっと Remotion x ゆっくり実況マスターになれることでしょう。
ちなみにコードは全部こちらのレポジトリに置いてあるので、Fork していただけるとさっと開発し始められます。

https://github.com/kazuyaseki/yukkuremotion-playground

Remotion プロジェクトの作成

まずはプロジェクトを作ります。

npm init video

を実行するとこんな感じでスターターがいくつか表示されるので選ぶだけで Remotion での開発を開始することができます。
デフォルトのものが React + TypeScript でスタンダードなのでとりあえずこれを選びます。
ちなみにデフォルトの .prettierrc で "useTabs": true, となっているのでスペース派閥の方はご注意ください。

そして npm run start すると Cool なエディターが立ち上がります!!

Remotion の基本

字幕の表示

字幕はいたって簡単です。
こんな感じで描画して配置するだけです。普通の Web 制作と全く変わらないですね。

function Jimaku() {
   return <div style={jimakuBackground}>テキスト</div>
}

const jimakuBackground: React.CSSProperties = {
  position: 'absolute',
  width: '100%',
  height: `${SUBTITLE_HEIGHT_PX}px`,
  bottom: 0,
  backgroundImage: `url(${staticFile('image/telop/Cyber_telop2_black.png')})`,
  backgroundPosition: 'center',
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  zIndex: 1,
};

画像の表示

画像は Img コンポーネントを使って表示します。

https://www.remotion.dev/docs/img

<Img src={staticFile("hi.png")} />

BGM の再生

動画の重要な構成要素である BGM ですが Audio というコンポーネントを使うと流すことができます。
https://www.remotion.dev/docs/audio

まずは流したい音声ファイルを public フォルダに配置します。
そして次のように Audio コンポーネントを使うと流せます。

import { Audio, staticFile } from "remotion";

export const MyVideo = () => {
  return (
    <Audio src={staticFile("audio.mp3")} />
  );
};

いろいろ Props を指定することができ、volume を変更することはもちろん loop させたりなどができます。

動画の再生

動画の再生も似たように Video というコンポーネントと使えば流すことができます。
https://www.remotion.dev/docs/video

import { Video, staticFile } from "remotion";

export const MyVideo = () => {
  return (
    <Video src={staticFile("video.mp4")} />
  );
};

ゆっくりの顔コンポーネントを作成

ゆっくり実況の要、霊夢と魔理沙の顔コンポーネントを作ります。

まずは素材をこちらからゲットします。色々ありますが、今回は一番親しみがあるものにしました。
http://www.nicotalk.com/charasozai_yk.html

の画像を使うと瞬きや口パクと相性が悪かったので, ディレクトリの画像を用いています。ただ にしかない表情もあるっぽいのでいつか足すかもしれないです。

export const PureFace = (props: {
  faceSizePx: number;
  imageDirectory: string;
  eyeImage: string;
  mouthImage: string;
}) => {
  const {faceSizePx, imageDirectory, eyeImage, mouthImage} = props;

  return (
    <div
      style={{
        ...containerStyle,
      }}
    >
      <Img
        style={{width: `${faceSizePx}px`}}
        src={staticFile(`${imageDirectory}/body/00.png`)}
      />
      <Img
        style={{
          ...faceStyle,
          width: `${faceSizePx}px`,
        }}
        src={staticFile(`${imageDirectory}/eye/${eyeImage}.png`)}
      />
      <Img
        style={{
          ...faceStyle,
          width: `${faceSizePx}px`,
        }}
        src={staticFile(`${imageDirectory}/mouth/${mouthImage}.png`)}
      />
    </div>
  );
};

const MemoizedFace = memo(PureFace, (prevProps, nextProps) => {
  return (
    prevProps.eyeImage === nextProps.eyeImage &&
    prevProps.mouthImage === nextProps.mouthImage
  );
});

ちなみにメモ化していますが、これは後に口パクや瞬きを実装した時に対策していないと再描画がエグいことになるのでしっかりメモ化しておきます。

フォントの読み込み

Google fonts を使う場合はライブラリがあるので、それをインストールして読み込むだけです。

npm i @remotion/google-fonts
import { loadFont } from "@remotion/google-fonts/TitanOne";

const { fontFamily } = loadFont();

const GoogleFontsComp: React.FC = () => {
  return <div style={{ fontFamily }}>Hello, Google Fonts</div>;
};

ローカルフォントを読み込みたい場合はこんな感じのコードを書いて、動画の冒頭に読み込みます。
ちなみにフォントファイルの format によって渡すキーワードは変わるので、それはこちらからご参照ください。
https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face

import { continueRender, delayRender, staticFile } from "remotion";
 
const waitForFont = delayRender();
const font = new FontFace(
  `LanobePOP`,
  `url(${staticFile("font/LanobePOPv2/LightNovelPOPv2.otf")}) format('opentype')`
);
 
font
  .load()
  .then(() => {
    document.fonts.add(font);
    continueRender(waitForFont);
  })
  .catch((err) => console.log("Error loading font", err));
  
function Moji() {
  return <p style={{ fontFamily: "LanobePOP" }}>Some text</p>
}

https://www.remotion.dev/docs/fonts

Sequence の理解

という感じで大まかな動画の構成要素の表示の仕方を見てきましたが、これらのほとんどは一つの動画内でずっと表示させるわけではありません。
そこで動画内のどこからどこまで表示するのか、を指定するためには Sequence というコンポーネントを活用する必要があります。

https://www.remotion.dev/docs/sequence

Sequence では durationInFrames でその Sequence がどのくらいのフレーム数続くかを指定することができ、from でどのフレーム数から開始するかを指定できます。

const MyTrailer = () => {
  return (
    <>
      <Sequence durationInFrames={10}>
        <Intro />
      </Sequence>
      <Sequence from={10}>
        <Clip />
      </Sequence>
      <Sequence from={20}>
        <Outro />
      </Sequence>
    </>
  );
};

「え、これ連続して再生するためにfrom全部指定しなきゃいけないの...?」と不安に思われた方もいるかもしれません。
それに対しては Series を使うと from を指定しなくても「前の Sequence が終わったら次の Sequence を再生」ということができるようになります。

https://www.remotion.dev/docs/series

import { Series } from "remotion";
 
const Example: React.FC = () => {
  return (
    <Series>
      <Series.Sequence durationInFrames={40}>
        <Square color={"#3498db"} />
      </Series.Sequence>
      <Series.Sequence durationInFrames={20}>
        <Square color={"#5ff332"} />
      </Series.Sequence>
      <Series.Sequence durationInFrames={70}>
        <Square color={"#fdc321"} />
      </Series.Sequence>
    </Series>
  );
};

Remotion におけるアニメーション

Remotion においてアニメーションをさせる上での大原則は「useCurrentFrame を使って frame に応じてアニメーションさせる」ということです。
どういうことかを説明するために、まずは frame を使わないパターンでアニメーションを書いてみます。

ただの CSS アニメーションを JSX に埋め込んだものです。

export const fuyofuyoAnimationCss: React.CSSProperties = {
	animation: `${fuyofuyoAnimationName} ${fuyofuyoAnimationDurationSec}s infinite ease-in-out`,
	transform: `translateY(${fuyofuyoFrequencyPx}%)`,
};

export const FuyoFuyoAnimationStyle = () => (
	<style>
		{`@keyframes ${fuyofuyoAnimationName} {
        0% {
          transform: translateY(${fuyofuyoFrequencyPx}%);
        }
        50% {
          transform: translateY(-${fuyofuyoFrequencyPx}%);
        }
        100% {
          transform: translateY(${fuyofuyoFrequencyPx}%);
        }
      }`}
	</style>
);

さて、これだと開発中は実はそれっぽく動くのですが、エンコードすると荒ぶります。

https://twitter.com/sekikazu01/status/1609539728990429187?s=20&t=pjdw4xj4EZcbp6TymHE1Lw

これはなぜかというと、Remotion のアーキテクチャに起因するのですが、Remotion ではエンコードの高速化を実現するために並列して作れるようになっています。

つまり連続したフレームたちが違うスレッドでエンコードされている可能性があるわけです。こうなるとフレーム毎にアニメーションが始まるので上記のようなガビガビになるわけですね。
ちなみにお察しの良い方は「スレッド一個にすればいいんじゃね?」と思った方もいらっしゃるかもしれません、実際それで荒ぶらずに動くことが多いみたいです。

If your animation will not break if the frames are rendered in order, users often use the --concurrency=1 flag. This will fix flickering / choppiness in many cases and is a viable path if the effort of refactoring is too big. The drawback of this technique is that it is way slower and that the correct timing of the animations is still not guaranteed.

ただ、エンコードの速度は単純にスレッド数で考えると2倍4倍とかになるかもしれないので、せっかくなので Remotion の並列実行の利点を活かせるようにしましょう。

という訳で次に Frame に応じてアニメーションをさせてみます。
現在の frame 数は useCurrentFrame という hooks を使うと参照できるので、これを使って値を変えるようにします。
また、アニメーションするように interpolate という関数で「このフレームの時にこの値」という配列を指定しておくといい感じに中間の値も渡してくれます。

function getFuyoFuyoFrames(videoFrames: number, isReimu?: boolean) {
  const frames: number[] = [isReimu ? 0 : FuyoFuyoInterval / 2];
  const intervals: number[] = [FuyoFuyoRange];

  let left = videoFrames;
  while (left > FuyoFuyoInterval) {
    frames.push(frames[frames.length - 1] + FuyoFuyoInterval);
    left -= FuyoFuyoInterval;

    addFuyoFuyoRange(intervals);
  }

  if (left > 0) {
    frames.push(frames[frames.length - 1] + left);
    addFuyoFuyoRange(intervals);
  }

  return [frames, intervals];
}

export const YukkuriFace: React.FC<ReimuProps> = ({
  face,
  sizePx,
  isReimu,
  mouth,
}) => {
  const imageDirectory = useMemo(
    () => (isReimu ? 'reimu' : 'marisa'),
    [isReimu]
  );

  const frame = useCurrentFrame();
  const video = useVideoConfig();

  const [frames, intervals] = useMemo(
    () => getFuyoFuyoFrames(video.durationInFrames, isReimu),
    [video]
  );

  const translateY = interpolate(frame, frames, intervals, {
    easing: Easing.bezier(0.51, 0, 0.49, 1),
  });

  return (
    <div style={{transform: `translateY(${translateY}%)`}}>
      <Face
        face={face}
        sizePx={sizePx}
        imageDirectory={imageDirectory}
        isReimu={isReimu}
        mouth={mouth}
      />
    </div>
  );
};

https://www.remotion.dev/docs/use-current-frame
https://www.remotion.dev/docs/interpolate

オブジェクトから音声ファイルを作成する

ゆっくり実況を制作する上での難所、それはゆっくりの音声を生成することです。
一文一文生成して配置するのは中々に苦行、なので、これを自動的に生成することを試みました。

Aquestalk10 と Aqkanji2koe の導入

ゆっくりの音声は Aquestalk というツールで作られています。
これをプログラマブルに実行できる SDK があるのでダウンロードします。

こちらから "評価版" をダウンロードできます。
https://www.a-quest.com/download.html

ただ、こちらは名前の通りとりあえず使ってみるためのもので、そのままだと「「ナ行、マ行」の音韻がすべて「ヌ」になる制限があります。」
なので「ねえねえ霊夢」も「ぬえぬえ霊夢」と発音されて面白いです。

ちゃんとした発音をしてもらうためには開発ライセンスを手に入れる必要があるのですが、それはこちらから購入することができます。

今回は Aquestalk10 と Aqkanji2koe のライセンスを購入しました。
購入すると物理でライセンスキーが郵送されてくるので大切に保存しましょう。

https://store.a-quest.com/items/8529902
https://store.a-quest.com/items/7456666

Aquestalk はいくつかバージョンがあるのですが最新版が10みたいです。
実は皆さんが普段聴き慣れている霊夢と魔理沙の声は Aquesttalk1 でないと再現できないので、できればそれを使いたかったのですが、1 だと Mac 向けの SDK が存在しないため断念しました...。

ただ、開発者の方も世代交代したがって欲しがってるみたいなので皆さんも 10 を使っていきましょう!
https://nlab.itmedia.co.jp/nl/articles/1901/22/news098_3.html

node-aquestalk10 のインストール

しかし問題がこの SDK、C++ で書かれているため JS で扱えません。万事休すかと思ったのですが、なんと Node JS で動かせるライブラリを作った天才がいたのでこちらを利用させていただきます。

https://github.com/y-chan/node-aquestalk10
https://github.com/y-chan/node-aqkanji2koe

npm install node-aquestalk10 node-aqkanji2koe

ライブラリの設置

先ほど手に入れた評価版のライブラリをルートに vendor ディレクトリを作って配置します。

音声の生成

それでは実際に生成してみます!
こんな感じのコードを書くとバイナリが手に入るので、それを .wav ファイルとして書き込みます。

import AquesTalk10, {gVoice_F1} from 'node-aquestalk10';
import AqKanji2Koe from 'node-aqkanji2koe';
import {AqKanji2KoeSetDevKey, Aquestalk10DevKey} from './aquest-keys';
import {staticFile} from 'remotion';

const aquestalk = new AquesTalk10(
    './vendor/AquesTalk.framework/Versions/A/AquesTalk'
);
// 購入したライセンスキーをここに引数として渡してください
aquestalk.AquesTalkSetDevKey(Aquestalk10DevKey);

const aqkanji2koe = new AqKanji2Koe(
    './vendor/AqKanji2Koe.framework/Versions/A/AqKanji2Koe',
    './vendor/AqUsrDic.framework/Versions/A/AqUsrDic',
    './vendor/aq_dic_large'
);
// 同上
aqkanji2koe.AqKanji2KoeSetDevKey(AqKanji2KoeSetDevKey);

// 音声に読み上げやすい形の文字列に変換
const text = aqkanji2koe.AqKanji2KoeConvertUtf8("ゆっくりしていってね!");

// 音声バイナリの生成
const result = aquestalk.AquesTalkSyntheUtf16(gVoice_F1, text)

// ID 作って wav ファイルとしてカキコ
const id = uuidv4().replaceAll('-', '');
const filename = `${id}.wav`;						fs.writeFileSync(`./public/audio/yukkuri/${filename}`, result);

音声のチューニング

裏話として先ほど申し上げた通り Aquestalk10 だと普段聞き慣れている霊夢と魔理沙の声とは違います。
ただ、いくつかパラメータはいじれるので頑張って近づけてみます。
が、何度も聞いていてゲシュタルト崩壊したので最終的に妥協した値が以下です。

const SPEED = 115;

const ReimuVoice = {
  base: 0, // 声種
  volume: 100, // 音量
  pitch: 95, // 高さ
  accent: 80, // アクセント
  lmd: 110, // 声質
  fsc: 103, // 音程
  speed: SPEED,
};
const MarisaVoice = {...gVoice_F1, base: 0, speed: SPEED, lmd: 130, pitch: 75};

再生させる

後は書かれたコンフィグを元に再生するだけです。

      <Sequence durationInFrames={durationInFrames} from={from}>
        <SubtitleWithBackground
          subtitle={voiceConfig.textForDisplay || voiceConfig.text}
          speaker={voiceConfig.speaker}
        />
        {hasAudio &&
	  // 霊夢と魔理沙が同時に喋る場合は id の配列を作っています。
          (voiceConfig.ids && voiceConfig.ids.length > 0 ? (
            voiceConfig.ids.map((id) => {
              return (
                <Audio key={id} src={staticFile(`audio/yukkuri/${id}.wav`)} />
              );
            })
          ) : (
            <Audio src={staticFile(`audio/yukkuri/${voiceConfig.id}.wav`)} />
          ))}
      </Sequence>

連続して再生するための Sequence の作成

色々個別の要素を作ってまいりましたが、お次は「これらの配列を連続して流す方法」を実装していきます。

まず二つの大きな Sequence の塊を作ります。

  • TalkSequence
    • 字幕の表示、ゆっくり音声を流す、ゆっくり音声に紐づいた画像・動画・音声を表示・再生する
  • YukkuriSequence
    • ゆっくりの顔を変えたりする

本当は全部まとめたかったのですが、ゆっくりの顔は動画全体を通してふよふよアニメーションしており、音声毎に区切ってしまうと再描画されてしまい、アニメーションがガクッとなってしまうため一つの個別の Sequence として扱うことにしました。

TalkSequence

こちらはめっちゃシンプルですひたすらループして表示させてるだけです。

export const TalkSequence: React.FC<Props> = ({talks, fromFramesMap}) => {
  return (
    <>
      {talks.map((talk, index) => {
        return (
          <Talk
            key={talk.ids && talks.includes.length > 0 ? talk.ids[0] : talk.id}
            voiceConfig={talk}
            from={fromFramesMap[index]}
            meta={{talks, index}}
          />
        );
      })}
    </>
  );
};

export const Talk: React.FC<TalkProps> = ({voiceConfig, from, meta}) => {
  const hasAudio = Boolean(voiceConfig.id) || Boolean(voiceConfig.ids);
  const durationInFrames = getDurationInFrames(voiceConfig);

  return (
    <>
      <Sequence durationInFrames={durationInFrames} from={from}>
        <SubtitleWithBackground
          subtitle={voiceConfig.textForDisplay || voiceConfig.text}
          speaker={voiceConfig.speaker}
        />
        {hasAudio &&
          (voiceConfig.ids && voiceConfig.ids.length > 0 ? (
            voiceConfig.ids.map((id) => {
              return (
                <Audio key={id} src={staticFile(`audio/yukkuri/${id}.wav`)} />
              );
            })
          ) : (
            <Audio src={staticFile(`audio/yukkuri/${voiceConfig.id}.wav`)} />
          ))}
      </Sequence>

      {他にも Talk に画像とか音声とか動画とかが紐づいていたら表示する}
    </>
  );
};

YukkuriSequence

こちらは霊夢と魔理沙を表示しています。

export const YukkuriSequence: React.FC<Props> = () => {
  return (
    <Sequence>
      <div style={reimuStyle}>
        <YukkuriFace isReimu />
      </div>
      <div style={marisaStyle}>
        <YukkuriFace isReimu={false} />
      </div>
    </Sequence>
  );
};

const reimuStyle: React.CSSProperties = {
  position: 'absolute',
  right: '10px',
  bottom: '180px',
  zIndex: 10,
};

const marisaStyle: React.CSSProperties = {
  position: 'absolute',
  left: '-5px',
  bottom: '180px',
  zIndex: 10,
};

そしてこの YukkuriFace の中で口パクとか瞬きとか色々やっているので次にそれを解説していきます。

音声データから口パクの生成

次にゆっくりにおける重要な要素「口パク」を作っていこうと思います。
まず結論から言うと「フレーム毎の画像のパスの配列を作る」というの愚直にやることにしました。

具体的に言うとこんな感じの配列を作って

// これの配列の長さ = 動画全体のフレーム数
export const ReimuMouthByFrame = ["05","05","05","05","06","04","02",...]

そしてフレーム毎にその配列から値をゲットするようにしてます。

export const Face = () => {
    ...

    const frame = useCurrentFrame();

  const mouthImageStr = useMemo(
    () => (isReimu ? ReimuMouthByFrame[frame] : MarisaMouthByFrame[frame]),
    [frame]
  );

  return (
    <MemoizedFace mouthImage={mouthImageStr} />
  );
};

こうなった背景として「ランタイムで口パクの状態を作るようにしたらなんか全然動かなかったので静的にビルドして解決するようにした」、というのがあります。(ただこれ主に再描画起きまくってたのが原因ぽいからメモ化したら解決してたかも)

こんな感じのスクリプトを走らせて、喋っている間はアニメーションされるように口パクのファイルを指定しています。

  // 動画全体のフレームの長さの配列をデフォルトの口(05)で埋めて作る
  const ReimuMouthByFrame = new Array(totalFrames).fill('05');
  const MarisaMouthByFrame = new Array(totalFrames).fill('05');

  FirstVideoConfig.sections.forEach((section, sectionIndex) => {
    const beforeFrames = getTotalFramesBeforeSection(
      FirstVideoConfig,
      sectionIndex
    );
    section.talks.forEach((talk, index) => {
      const startFrame = beforeFrames + section.fromFramesMap[index];
      for (let i = startFrame; i < startFrame + talk.audioDurationFrames; i++) {
        if (talk.speaker === SPEAKER.reimuAndMarisa || talk.speaker === SPEAKER.reimu) {
            for (
              let i = startFrame;
              i <= startFrame + talk.audioDurationFrames;
              i++
            ) {
	            // 全部の口パク素材使うなら 00,01,02,03,04,05 までを使うのだが、
	      // 1フレーム毎の全部使うとなんだか口パクが遅かったので半分ぐらいに間引いている
	      // 60FPS だと全部使ってもいいのかも
              const index = Math.abs(3 - ((i - startFrame) % 6));
              let imageIndex = 6;
              switch (index) {
                case 0:
                  imageIndex = 0;
                  break;
                case 1:
                  imageIndex = 2;
                  break;
                case 2:
                  imageIndex = 4;
                  break;
                case 3:
                  imageIndex = 6;
                  break;
                default:
                  imageIndex = 0;
              }
              ReimuMouthByFrame[i] = imageIndex.toString().padStart(2, '0');
            }
        }
        if (talk.speaker === 'reimuAndMarisa' || talk.speaker === 'marisa') {
          // ほぼ同じ処理なので割愛
        }
      }
    });
  });

  fs.writeFileSync(
    `./transcripts/MouthByFrame.ts`,
    `export const ReimuMouthByFrame = ${JSON.stringify(ReimuMouthByFrame)};
export const MarisaMouthByFrame = ${JSON.stringify(MarisaMouthByFrame)};				`
  );

割とトライ & エラーを繰り返してこの形に辿り着きましたが、結構無理やり感はありますが、シンプルに収まっていい感じなのではという気がしています。

失敗例: 波形データから音の大きさに応じて開く・閉じるを指定する

最初は無駄に凝ろうとしてたのでその例も供養として紹介しておきます。

最初に考えたのは音の波形データから音の大きさを取り、その値を元にフレーム毎に口を閉じる・開けるを切り替えれば自然な口パクになるのではということでした。

まず波形データを取る方法ですが、これに関しては Remotion が getWaveformPortion という関数を提供しています。

https://www.remotion.dev/docs/get-waveform-portion

ただ、これに渡す AudioData を取得する関数が node.js では動かないのでちょっと一工夫入れました。(ランタイムで音声ファイルから上記データを取ろうとするとエグい重くなったので、ビルド時に解決したい)

とりあえずロジックとしてはこちらの getAudioData 関数をおパクリさせていただくのですが、ブラウザでしか動かないところを Node でも動くようにしていきます。
https://github.dev/remotion-dev/remotion/blob/23cd53b785eecece9fc55f4b33b7445a843126c0/packages/media-utils/src/get-audio-data.ts#L1

具体的には AudioContext がブラウザの Web Audio API に生えているものなので、これを Node でも動かすためのライブラリである web-audio-engine を導入して解決しました。(アーカイブされているのが一抹の不安を感じますが)

https://github.com/mohayonao/web-audio-engine

import wae from 'web-audio-engine';

const originalGetAudioData = async (src: string): Promise<AudioData> => {
    const audioContext = new AudioContext();

    const file = fs.readFileSync(src);
    const wave = await audioContext.decodeAudioData(file);

    const channelWaveforms = new Array(wave.numberOfChannels)
        .fill(true)
        .map((_, channel) => {
            return wave.getChannelData(channel);
        });

    const metadata = {
        channelWaveforms,
        sampleRate: audioContext.sampleRate,
        durationInSeconds: wave.duration,
        numberOfChannels: wave.numberOfChannels,
        resultId: uuidv4(),
        isRemote: false,
    };
    
    return metadata;
};

この関数で音声データが取れるようになりました。
そしたら下記のような感じで波形データのフレーム毎の音の大きさのマップを作ります。
ちなみに frames と amplitude という二つの配列を個別に作っていますが、これは後ほど Remotion の interpolate というアニメーションさせるための関数の引数に渡しやすくするためです。

const numberOfSamples = 24;
// 音声の波形データから「どのフレームで」「どの口になるかを指定するマップを作成」
const waveformPortion = getWaveformPortion({
    audioData,
    startTimeInSeconds: 0,
    durationInSeconds: audioData.durationInSeconds,
    numberOfSamples,
});

const audioFragmentFrame = Math.floor(
    (audioData.durationInSeconds * FPS) / numberOfSamples
);

waveformPortion.forEach((bar, index) => {
    const frame = section.fromFramesMap[talkIndex] + audioFragmentFrame * index;
	
    if (!section.kuchipakuMap.frames.find((f) => f === frame)) {
        const lastFrame = talkIndex > 0 ? section.fromFramesMap[talkIndex - 1] : -1;
        const currentFrame = section.fromFramesMap[talkIndex] + audioFragmentFrame * index || 0;

    if (currentFrame > lastFrame) {
        section.kuchipakuMap.frames.push(
            section.fromFramesMap[talkIndex] + audioFragmentFrame * index || 0
        );
        section.kuchipakuMap.amplitude.push(bar.amplitude);
    }
}

という感じで作ってみたのですがなんか微妙な感じになりました...(動画撮り忘れた)
これの後「もう喋ってる間ずっと口パクしてればいいのでは?」と考え試したところ「これでよくね?」となり今の形に落ち着きました。

ちなみに口パクは英語名では lip sync と呼ぶみたいなのでアルゴリズムを調べたい方はそのキーワードを使ってみてください。

あとゆっくりMovieMakerで使われている? KuchiPaku なるライブラリが紹介されていたのでこちらも勉強になると思います。
https://github.com/InuInu2022/KuchiPaku

動画のエンコード

最後に、アップロードするための鬼門、エンコードについてです。
package.json に build コマンドが作られているので、ビルド対象の Composition 名を指定して実行すると mp4 ファイルが作られます。

{
  "scripts": {
    "build": "remotion render FirstVideo out/video.mp4",
  }
}

ただ私の場合は次のようなタイムアウトエラーが起きてしまいました...

(1/3) [====================] Bundled code 1259ms
Entry point = src/index.ts (common paths), Composition = FirstVideo (Passed as argument), Codec = h264 (derived from out name), Output = out/video.mp4
(2/3) [============        ] Rendering frames (4x) 11619/18000
    + [====================] Downloading 121 files
(3/3) [                    ] Encoding video 0/18000
An error occurred:
TimeoutError: waiting for function failed: timeout 30000ms exceeded

あるある話っぽくこちらに解決策まとめがありました。
https://www.remotion.dev/docs/timeout

ただとりあえずはで言うとこちらの issue 内で紹介されているように concurrency を指定するだけで私の場合は解決できました。
https://github.com/remotion-dev/remotion/issues/214

npm run build -- --concurrency=2

これにプラスして Remotion は lambda でビルドするという選択肢も用意してくれています。最大 3000スレッドという訳分からん並列数で実行してくれるため超高速です。
https://www.remotion.dev/docs/lambda/setup

ちなみにセットアップの際に npx remotion コマンドたちを実行するときに、我々は Japan にいるので --region=ap-northeast-1 をつけるのを忘れないようにしましょう。ただしこのリージョンだと最大1000スレッドになるみたいで、us-east-1, us-west-2, eu-west-1 あたりが推奨されてはいます。ファイルそこまでアップロード頻度なければリージョンこっちでもいいかもしれません。

https://www.remotion.dev/docs/lambda/region-selection

Video タグで表示した動画がカクつく場合

私がパフォーマンス以外に遭遇した問題として、Video コンポーネントで表示した動画がカクつくという問題がありました。

https://www.youtube.com/watch?v=1bh6zwTzcdU

原因は未だ謎なのですが、Canva というツールで作った動画だけこのような現象が起きてしまいました。
これは Video の代わりに OffthreadVideo というコンポーネントを使うことで解決できました。

https://www.remotion.dev/docs/offthreadvideo

使い方は Video と全く一緒です。

<OffthreadVideo src={staticFile("video.webm")} />

なんでも描画中に動画として再生するのではなく、画像に変換しているんだとか。

ただ、一つ注意点として OffthreadVideo はループさせることができません。なので公式サイトにもあるスニペットですが、次のようなコンポーネントを作って対処します。

import { getVideoMetadata } from "@remotion/media-utils";
import React, { useEffect, useState } from "react";
import {
  continueRender,
  delayRender,
  Loop,
  OffthreadVideo,
  staticFile,
  useVideoConfig,
} from "remotion";
 
const src = staticFile("myvideo.mp4");
 
export const LoopedOffthreadVideo: React.FC = () => {
  const [duration, setDuration] = useState<null | number>(null);
  const [handle] = useState(() => delayRender());
  const { fps } = useVideoConfig();
 
  useEffect(() => {
    getVideoMetadata(src)
      .then(({ durationInSeconds }) => {
        setDuration(durationInSeconds);
        continueRender(handle);
      })
      .catch((err) => {
        console.log(err);
      });
  }, [handle]);
 
  if (duration === null) {
    return null;
  }
 
  return (
    <Loop durationInFrames={Math.floor(fps * duration)}>
      <OffthreadVideo src={src} />
    </Loop>
  );
};

エンコード待ち時間でPCをスリープさせないようにする(Mac編)

もはや Remotion 関係ないですが、ローカルでエンコードさせようとすると思ったより時間かかります。
例えば私の8分の動画は40分かかりました。
その間スリープしてしまうと処理が中止されてしまいます。

方法1: YouTube でやたら長い BGM を流す

一番手軽な方法です。
こういう感じのやつ。
https://www.youtube.com/watch?v=ElkQe9O1vck

方法2: Mac の設定をいじる

Mac の設定のバッテリーからスリープさせないようにします。

方法3: アプリを入れる

こういうやつ
https://apps.apple.com/jp/app/amphetamine/id937984704?mt=12

方法4: ライブラリを入れる

私はめんどくさくて試してないですがこういう方法もありそうです。

https://twitter.com/sekikazu01/status/1609601978107322370?s=20&t=tCReM986fCldarGlqiQSXg

余談: 素材探し、作り

ここはもはやプログラミング関係ないのですが、動画制作初体験をするにあたってお世話になった素材サイトがいくつかあるので敬意を表して紹介します。

DOVA-SYNDROME

フリーの BGM サイトです。
https://dova-s.jp/

とりあえずこのプレイリストを聞いてみてください。YouTube や TikTok などで聞いたことがあるBGMばかりで、このサイトの凄さが感じられると思います。日本の YouTuber 界隈を支えていると言っても過言ではないでしょう。
https://youtube.com/playlist?list=PL2vyqKNLXiUpl8AoOKI3rMrEzLGx_-1m2

Canva

Canva は今までオシャレな請求書を作るくらいにしか使ったことがなかったのですが、なんと動画素材も多種あります。今回作った動画素材も大体 Canva で作っているし、サムネも Canvaです。有用過ぎるので迷わず Pro プラン契約しました。

Canva 最強!!

https://www.canva.com/

motionelements

動画に関するありとあらゆる素材があります。
私は途中から Canva で全て事足りるようになりましたが、物足りない場合は利用してみてもいいでしょう。
https://www.motionelements.com/ja/

Telop.site

名前の通りフリーのテロップフレームが大量にあるサイトです。神。
https://telop.site/

おわりに

以上 React でゆっくり解説動画を作った軌跡を綴ってまいりました。
他にも細かい設定ができるようにした部分は色々あったのですが、大変だったのは上記の部分です。
大変なことは多少ありましたが、一度基盤を作ってしまえば高速に同じ型の動画が作れるなと感じております。
私もせっかくなので今後色々ゆっくり解説動画を作っていこうと思います。

実務に活かせるところがあるかはまあまあ微妙ですが、趣味で動画制作を始めたい方にはいいんじゃないでしょうか。
それでは、最後までお読みいただきありがとうございました!

Discussion

ログインするとコメントできます