毎週投稿に耐えられる仕組みを作りたい!
私は個人の活動としてゲームの制作を行っています。
近年は個人クリエイターの情報発信の重要性が高まっており、複数のプラットフォームで継続的に活動する必要性を感じています。
ブログのようなテキスト中心の情報発信も有効ですが、ゲーム制作の場合は画面の動きが重要になる場面も多く、動画との相性がとても良いと感じています。
一方で「ゲーム制作」と「動画制作」を並行するのは容易ではありません。
動画編集には相応の時間が必要で、ここに時間を割きすぎると本来進めたかったゲームの制作そのものが停滞してしまいます。
そこで私は、毎週1本のショート動画を無理なく投稿し続けるために、動画制作のフローそのものを仕組み化し、コードベースで構築しました。
この記事では、今年の4月から継続して運用しているその制作フローと実践内容を紹介します。
作成する動画
私が作成している動画は、以下のような構成になっています。
- 1分未満の縦長のショート動画
- 画面の中央に画像や映像を表示
- 画面下部でキャラクターが読み上げ音声で喋る
動画内に表示する画像や映像、セリフは毎週変わりますが、構成とレイアウトはほぼ固定です。そのため、可変部分だけを「原稿データ」として用意し、それを読み込ませてテンプレートに合わせて動画を生成する方法が適していました。
ここで、その「動画のテンプレート」を React コンポーネントとして定義できる Remotion を採用しました。
Remotion とは
Remotion は React を用いて動画を作成するツールです。
Remotion では「動画の1シーン」「テロップ」「画像」「キャラクター表示」などを React コンポーネントとして定義し、それを組み合わせることで動画を作成できます。
そのため、 Web ページを作る感覚で動画を作成できます。
個人の場合は無料で利用できます。
本記事は v.4.0.375 を使用しています。
制作フローのイメージ
今回の動画制作フローは以下のようになります。
- 動画に使用する画像や映像を用意する
- 原稿となるスクリプトファイルを作成する
- スクリプトファイルから、 Remotion の Component に渡す Props / 読み上げ音声の生成を行う
- Remotion 上で動画化する
(1) はゲーム制作の進捗や発信したい内容に合わせて画像や映像を用意します。
(3) 以降は CLI で半自動化しており、毎週の作業はスクリプトを書くことと素材を用意することに集中できます。
スクリプト
ここで言うスクリプトは、プログラムのコードというより、動画の台本(スクリプト)を記述するための DSL 的なものです。
運用を始めたころは Remotion に渡す Props (JSON) を直接手で書いていましたが、記述量が多く毎週の作業としては負担が大きいものでした。そこで、人間にとってわかりやすい形で台本を作成し、そこから JSON を生成する方針に切り替えました。
どのようなものを使っても良いのですが、ここでは私が自分のゲーム用に作っているノベルゲームエンジン風の記法である『MNSC』を使用しています2。
---
title:
text: "自作ゲーム用の\n背景画像を用意する話"
fontSize: 96
category: "自作ゲーム制作"
voice:
narrator: "Japanese Female 1"
speed: 120
pitch: 30
---
<<bgm('../_assets/bgm.ogg', volume: 0.2)>>
<<image('./pic/nymy2.png', layout: 'contain')>>
#id:fff230b9-ecb7-423b-a756-a9b95cb3ff65
rutan: face: 'normal'
[t=0.5]今回は
[t=1.4]主人公の部屋をイメージした
[t=1.9]背景画像を用意していきます。
<<image('./pic/example.png', layout: 'contain', transition: 'wipe')>>
#id:8c0126fd-0eba-4133-b3d7-6e1932b05c77
rutan: face: 'aa'
[t=1.3]僕は背景画像には
[t=1.2]写真素材を使うことが
[t=0.8]多いです。
#id:639ef2d5-50ac-4af2-b894-0940ae7d9e90
rutan: face: 'gununu'
[t=1.6]だけど室内の写真素材は
[t=0.7]意外とニッチで
[t=1.7]あまり多くないんですよね。
- Frontmatter部分
- タイトルなどのメタデータ
- メインコンテンツ部分
-
<<コマンド名(引数)>>形式の命令 - 話者指定のセリフ指定
-
というシンプルな構成です。
[t=数値] の部分は、画面に表示する字幕の表示時間(秒)を示しています。
Props(JSON)の生成
まず、前述のスクリプトをもとに、 Remotion のコンポーネントに渡す Props を生成します。
今回作成する動画は以下のようなコンポーネント構成になっています。
スクリプトでは人間にとってのわかりやすさを重視して、すべてをまとめた一つの流れとして記述しています。
しかし、実際にコンポーネントを作成することを考えると、
「画像や映像表示のコンポーネントには画像や映像の情報のみ」
「セリフのコンポーネントにはセリフの情報のみ」
といったように、各コンポーネントに必要な情報だけを渡せる形のほうが扱いやすくなります。
そのため、DSL からパースした情報をもとにタイムラインを分解し、各シーケンスの開始時刻と長さを計算したデータ構造を用意しています。
// This file is auto-generated by scripts/buildScenario.ts
import type { VideoScenario } from "./entities";
export const scenario: VideoScenario = {
"meta": {
"title": {
"text": "自作ゲーム用の\n背景画像を用意する話",
"fontSize": 96
},
"category": "自作ゲーム制作",
"voice": {
"narrator": "Japanese Female 1",
"speed": 120,
"pitch": 30
}
},
/* BGM用のシーケンス */
"bgmTimeline": [
{
"startTime": 0,
"duration": 43.800000000000004,
"src": "path/to/bgm.ogg",
"volume": 0.2
}
],
/* 画面中央の画像/映像エリアのシーケンス */
"displayTimeline": [
{
"startTime": 0,
"duration": 3.8,
"layout": "contain",
"command": "image",
"src": "path/to/pic/nymy2.png"
},
{
"startTime": 3.8,
"duration": 9.2,
"layout": "contain",
"transition": "wipe",
"command": "image",
"src": "path/to/pic/example.png"
},
...
],
/* キャラクターエリアのシーケンス */
"characterTimeline": [
{
"startTime": 0,
"duration": 3.8,
"name": "rutan",
"face": "normal"
},
{
"startTime": 3.8,
"duration": 3.3,
"name": "rutan",
"face": "aa"
},
...
],
/* セリフ(字幕)のシーケンス */
"messageTimeline": [
{
"startTime": 0,
"duration": 0.5,
"text": "今回は"
},
{
"startTime": 0.5,
"duration": 1.4,
"text": "主人公の部屋をイメージした"
},
...
],
/* 読み上げ音声のシーケンス */
"talkVoiceTimeline": [
{
"startTime": 0,
"duration": 3.8,
"talk": "今回は主人公の部屋をイメージした背景画像を用意していきます。",
"src": "path/to/voice1.wav",
"isExist": true
},
{
"startTime": 3.8,
"duration": 3.3,
"talk": "僕は背景画像には写真素材を使うことが多いです。",
"src": "path/to/voice2.wav",
"isExist": true
},
...
],
/* メタ情報UI表示・非表示切り替えのシーケンス */
"overlayUiTimeline": [
{
"startTime": 0,
"duration": 43.800000000000004,
"show": true
}
],
/* 動画の長さ */
"duration": 43.800000000000004
};
Remotion 上ではこれを Props として受け取るだけで、各コンポーネントに必要な情報だけを簡単に渡すことができます。
タイムラインの計算を各コンポーネントで行う必要がないため、コンポーネント側の実装をシンプルに保つことができます。
読み上げ音声の生成
今回、読み上げ音声には VOICEPEAK 商用可能 6ナレーターセット を使用しました。VOICEPEAK はコマンドラインからの呼び出しが可能で、プログラム内から音声を出力させることができます。
$ path/to/voicepeak.exe \
-s "こんにちは" \
-o path/to/output.wav
DSL のパース時にあわせて音声の生成を行います。
注意点として、当然ながら音声の生成には相応の時間がかかります(筆者の環境で 1 ボイス 5 ~ 10 秒)。
そのため、生成キューを用意し、キューへ積んだあとに該当の文章が変更・削除された際はキャンセルできるようにしています。また、既に生成済みのものはスキップするなどキャッシュまわりの制御も入れています。
Remotion での実装
先ほど生成した JSON を使用して Remotion の Root コンポーネントを定義します。
import { Composition } from 'remotion';
/* DSL から生成したデータ */
import { scenario } from './scenario.gen';
/* レイアウトなどを設定した表示用コンポーネント */
import { PortraitComposition } from './compositions';
const FPS = 60;
export const RemotionRoot: React.FC = () => {
// 生成したデータから動画長を計算
const durationFrames = Math.round(FPS * scenario.duration);
return (
<>
<Composition
id="PortraitComposition"
component={PortraitComposition}
durationInFrames={durationFrames}
fps={FPS}
width={1080}
height={1920}
defaultProps={{
// DSL のデータを props として渡す
scenario,
}}
/>
</>
);
};
そして、各表示コンポーネントでは事前生成したシーケンス用の Props を受け取り、表示を行います。
Remotion には<Sequence> や、それを束ねる <Series> というコンポーネントが提供されており、それと組み合わせることで簡単にシーケンスを定義できます。
import { Img, Series, staticFile, useVideoConfig } from 'remotion';
import { CharacterTimelineItem } from '../entities';
export const CharacterArea = ({
characterTimeline,
}: {
characterTimeline: CharacterTimelineItem[]
}) => {
const { fps } = useVideoConfig();
return (
<Series>
{characterTimeline.map((item, index) => (
<Series.Sequence
key={index}
durationInFrames={Math.round(item.duration * fps)}
name={item.face}
layout="none"
>
<Img
src={staticFile(
`assets/characters/${item.name}/${item.face}.png`,
)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'center bottom',
}}
/>
</Series.Sequence>
))}
</Series>
);
};
フローの自動化:実際の生成プロセス
実際には以下のような手順で動画を作成しています。
(1) 動画の内容を決め、必要な画像や映像を用意する
制作したゲームの画像やキャプチャー動画を収録し、作業用フォルダに設置します。
(2) 原稿データを作成する
一旦この時点では尺や細かい言い回しなどはあまり考えず、言いたいことを出力します。
(3) 変換スクリプト+Remotionを起動して Web プレビュー
pnpm run dev path/to/原稿データ.mnsc のようなコマンドで、変換スクリプトが watch モードで実行されつつ、 Remotion の WebUI が立ち上がります。
ここで実際の表示を確認しながら、原稿データのテキスト調整や順序の並び替えを行います。大体動画長が40秒~60秒程度に収まるようにしています。
(4) 動画を出力する
Remotion の WebUI から動画生成の実行ができます。
ここまでの作業で約1時間程度になります。週次であれば続けられそうなくらいのコスト感ですね。
課題と改善
レイアウトの影響を考慮する
これは Remotion 固有の問題ではなく UI ライブラリを使う場合にも共通することですが、生成される DOM を意識する必要があります。
当初は Flexbox ベースのレイアウトを行っていました。
後に、一部シーンで映像エリアをフルスクリーンにする機能を追加したくなり、一時的に position: absolute を使うことを考えたのですが、トランジション付きのシーケンスを定義する <TransitionSeries> が生成する DOM 内で position を利用しており、意図したレイアウトが実現できませんでした。
そのため、現在の実装では大きなレイアウトについては position: absolute を多用した絶対配置を行っています。
縁付きの文字
動画の字幕には、やはり太い縁取りが欲しいところです。
ですが、 CSS の -webkit-text-stroke は縁取りが文字の内側にも入ってくるため、太い縁を設定すると見た目があまりよくありません。
そのため字幕については SVG でテキストを表示するようにしています。その影響で自動折り返しなどが簡単にはできず、その調整は手動で頑張るようにしています。
// 原稿データの時点で、折り返し地点を決めている
rutan: face: 'aa'
[t=1.3]僕は背景画像には
[t=1.2]写真素材を使うことが
[t=0.8]多いです。
アニメーションの難しさ
まるで Web のように動画を作成できる Remotion ですが、アニメーションに関しては難しさがあります。
Remotion では最終的な動画としてのレンダリングは並列で行われるため、「現在の再生時間(フレーム数)」が渡されて、それを用いて画面を構築する必要があります。つまり CSS の Transition のような自動で動く仕組みは、実際に動画を生成するとうまくレンダリングされません。
そのため、以下のように自分でアニメーションの座標を計算する必要があります。
// 0.5秒かけて下からにゅっとでてくる
const MyAnimationComponent = () => {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
const appearDuration = fps * 0.5;
const appearProgress = Math.min(1, frame / appearDuration);
return (
<div
style={{
transform: `translateY(${(1 - appearProgress) * 100}%)`,
}}
>
...
</div>
);
};
もちろん頑張れば書けなくはないですが、動きの多い動画を作ろうとすると、複雑度が許容できないレベルになる可能性があります。ここは私自身もどう向き合っていくのが良いかは現時点では解が見つかっていません。
2025年12月2日追記: animation-play-state: paused を使うと良さそう
記事公開後、同僚から「 animation-play-state: paused と animation-delay を使えばできそうではないか」という意見をもらいました。
// イメージ
const keyframesStyle = `
@keyframes slideInFromBottom {
0% { transform: translateY(100%); }
100% { transform: translateY(0); }
}
`;
export const SampleComponent = () => {
const { fps } = useVideoConfig();
const frame = useCurrentFrame();
return (
<div>
{/* 行儀はよくないけどひとまず…… */}
<style>{keyframesStyle}</style>
<div
style={{
animation: "slideInFromBottom 1s ease-in-out",
// 動きは止めておく
animationPlayState: 'paused',
// animation-delay で開始位置を負の値にする
animationDelay: `-${frame / fps}s`,
}}
>
...
</div>
</div>
);
};
確かに、この方法であればプレビュー時だけでなくレンダリング時にも CSS 側の仕組みでアニメーションを動かすことができました。座標計算を自前でする必要はないですし、かなり楽することができそうです。
すごい発明だ……!と感動していたところ、実はそれをライブラリ化している方や、そもそも公式の GitHub に animation-play-state を使ったリポジトリが存在していました。
- https://github.com/ahgsql/remotion-animation/
- https://github.com/remotion-dev/css-animation-play-state/tree/main
僕が……あまりに何も見ていなかった……!!
時間指定かフレーム数指定か
今回は DSL で指定する際に「時間(秒)」で指定をしています。
一方で、 Remotion 上で時間を取り扱う場合、フレーム数(時間 × FPS)で指定する必要があります。これを考慮すると、最初からフレーム数ですべてを指定していたほうが、計算で小数点以下の端数も出なくて良いかもしれません。
まとめ
今回、 Remotion を使うことで毎週続けられる動画作成の仕組みを構築しました。
私は個人で Adobe Premiere のようなソフトウェアを使うこともありますが、細かいところまで制御しようとすると膨大に時間がかかってしまいます。 Remotion は機能自体はシンプルで、普段慣れ親しんでいる Web / React の技術で向き合うことができるため、私にとってはかなりマッチするツールだと感じました。
React コンポーネントであるという点から流用なども比較的しやすく、縦長動画用のコンポーネントを流用して、横長動画を生成する……といったこともできます。
実際に使っているプロジェクトから素材などを抜いたものを以下のリポジトリで公開しています。もし Remotion での動画作成に興味がありましたら参考にしてみてください。
まだまだ運用としては改善できる点もあるだろうなと思うため、これからも可能な限り動画作成を続けていけるといいなと思います。
……ところで、制作の進捗や、動画のネタも Remotion が生成してくれませんかね……? 無理ですか、そうですか……(◞‸◟)
以上、 ドワンゴ Advent Calendar 2025 の 1 日目でした。
明日もお楽しみに!
-
Yarn Spinner の影響を受けたオレオレノベルDSLです。「Minimal Narrative Script Code」の略……というのは後付けで「メモリーニミィ(Memory Nymy)」というゲームの台本(スクリプト)を書くために作ったものです。 ↩








