RemotionでリリックビデオとMVを作る:歌詞同期・カラオケアニメーション・バッチレンダリング完全ガイド
RemotionでリリックビデオとMVを作る:歌詞同期・カラオケアニメーション・バッチレンダリング完全ガイド
リリックビデオはプログラマティック動画制作に最も適したコンテンツのひとつです。コンテンツの構造がデータとして明確に定義されており(タイムスタンプ付きの歌詞)、視覚的な処理もパターン化されています(テキストが音楽に合わせて現れ、単語ごとにハイライトされ、バックグラウンドがリズムに呼応する)。After Effectsで一語一語を手動でキーフレームするより、コードで一度ロジックを書いてデータで動かすほうが圧倒的に効率的です。
この記事ではリリックビデオ制作のパイプライン全体を解説します。歌詞データの構造化から始まり、タイムスタンプのフレーム変換、単語ごとのハイライトアニメーション、パーティクルや色グラデーションによるバックグラウンド生成、BGMの追加、1080p・4Kレンダリング、そしてアルバム全曲を一括でレンダリングするバッチ処理まで、実装レベルで踏み込んで説明します。
歌詞データの設計
リリックビデオの土台は歌詞JSONファイルです。最もシンプルな構造はライン単位のタイムスタンプです。
[
{ "start": 0.0, "end": 2.4, "text": "目を閉じれば 聞こえてくる" },
{ "start": 2.4, "end": 4.8, "text": "遠い夏の声が" },
{ "start": 4.8, "end": 7.2, "text": "風に乗って 届く" },
{ "start": 7.2, "end": 9.6, "text": "あの頃と変わらずに" }
]
単語単位のカラオケハイライトを実装する場合は、より細かい粒度が必要です。
[
{
"start": 0.0,
"end": 2.4,
"words": [
{ "start": 0.0, "end": 0.35, "text": "目を" },
{ "start": 0.35, "end": 0.65, "text": "閉じれば" },
{ "start": 0.65, "end": 1.1, "text": "聞こえてくる" }
]
}
]
単語単位のタイムスタンプは、WhisperX(Pythonライブラリ、ローカル実行)やOpenAI Whisper APIのword-level timestampsオプションで自動生成できます。歌詞が既知の場合は強制アライメント(forced alignment)のほうが精度が高いことが多いです。曲数が少なければ手作業で入力することも現実的です。
タイムスタンプをフレームに変換する
Remotionのタイムラインは秒ではなくフレーム単位で管理されます。変換は単純な乗算です。
const timestampToFrame = (seconds: number, fps: number): number => {
return Math.round(seconds * fps);
};
この関数を<Sequence>の配置に使います。<Sequence>はRemotonの基本的なタイミング制御コンポーネントで、指定したフレーム範囲内でのみ子要素をレンダリングします。
import { Sequence, useVideoConfig } from "remotion";
export const LyricTrack: React.FC<{ lyrics: LyricLine[] }> = ({ lyrics }) => {
const { fps } = useVideoConfig();
return (
<>
{lyrics.map((lyric, i) => (
<Sequence
key={i}
from={timestampToFrame(lyric.start, fps)}
durationInFrames={timestampToFrame(lyric.end - lyric.start, fps)}
>
<LyricLineComp lyric={lyric} />
</Sequence>
))}
</>
);
};
<Sequence from={60}>の中ではuseCurrentFrame()は0から始まります(コンポジション全体のフレーム60時点でも)。これにより、各ライン内のタイミング計算が相対的に書けて非常にシンプルです。
単語ごとのハイライトアニメーション
カラオケスタイルの核心部分です。ライン内の各単語に対して、その単語が発声されるタイミングでハイライトが入ります。
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
export const LyricLineComp: React.FC<{ lyric: LyricLine }> = ({ lyric }) => {
const frame = useCurrentFrame(); // このSequence開始からの相対フレーム
const { fps } = useVideoConfig();
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 10,
justifyContent: "center",
padding: "0 8%",
}}
>
{lyric.words.map((word, i) => {
const wordStartFrame = timestampToFrame(word.start - lyric.start, fps);
const isActive = frame >= wordStartFrame;
const glowProgress = interpolate(
frame,
[wordStartFrame, wordStartFrame + 5],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<span
key={i}
style={{
fontSize: 52,
fontWeight: 700,
fontFamily:
'"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
color: isActive
? `rgba(255, 210, 60, ${0.5 + glowProgress * 0.5})`
: "rgba(255, 255, 255, 0.45)",
textShadow: isActive
? `0 0 ${24 * glowProgress}px rgba(255, 210, 60, 0.85)`
: "none",
lineHeight: 1.4,
userSelect: "none",
}}
>
{word.text}
</span>
);
})}
</div>
);
};
アクティブになる前の単語は半透明の白、アクティブになった単語はゴールドのグロー効果で強調されます。interpolateでグロー強度を4〜5フレームかけてなめらかに立ち上げることで、瞬間的な切り替えではなく有機的なハイライトになります。
ライン全体の登場・退場アニメーション
単語ハイライトに加えて、ライン全体の出入りをアニメーションさせると格段にクオリティが上がります。
export const LyricLineComp: React.FC<{
lyric: LyricLine;
durationFrames: number;
}> = ({ lyric, durationFrames }) => {
const frame = useCurrentFrame();
const enterDuration = 10;
const exitStart = durationFrames - 10;
const opacity = interpolate(
frame,
[0, enterDuration, exitStart, durationFrames],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const translateY = interpolate(
frame,
[0, enterDuration],
[20, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return (
<div
style={{
position: "absolute",
bottom: "22%",
left: 0,
right: 0,
opacity,
transform: `translateY(${translateY}px)`,
}}
>
{/* 単語ハイライトのJSX */}
</div>
);
};
これで各ラインがふわりと下から浮かび上がって現れ、次のラインへ移る前にフェードアウトします。登場と退場のイージングはinterpolateの4点指定([0, enter, exit, duration])で自然に表現できます。
バックグラウンドビジュアルの生成
静止画のバックグラウンドはリリックビデオを平坦に見せます。SVGで動くパーティクルを生成するアプローチは、シンプルながら視覚的な奥行きを与えます。
const PARTICLE_COUNT = 50;
export const ParticleField: React.FC = () => {
const frame = useCurrentFrame();
const { width, height } = useVideoConfig();
const particles = Array.from({ length: PARTICLE_COUNT }, (_, i) => {
const seed = i * 113.5;
const baseX = (Math.sin(seed) * 0.5 + 0.5) * width;
const baseY = (Math.cos(seed * 1.7) * 0.5 + 0.5) * height;
const drift = 0.15 + (i % 7) * 0.06;
const y = (baseY + frame * drift) % height;
const opacity = 0.08 + (Math.sin(frame * 0.025 + seed) * 0.5 + 0.5) * 0.18;
const radius = 1.5 + (i % 5) * 1.2;
return { x: baseX, y, radius, opacity };
});
return (
<svg
width={width}
height={height}
style={{ position: "absolute", top: 0, left: 0, pointerEvents: "none" }}
>
<defs>
<radialGradient id="bg" cx="50%" cy="50%" r="70%">
<stop offset="0%" stopColor="#1e1b4b" />
<stop offset="100%" stopColor="#0f0a1e" />
</radialGradient>
</defs>
<rect width={width} height={height} fill="url(#bg)" />
{particles.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={p.radius}
fill="white"
opacity={p.opacity}
/>
))}
</svg>
);
};
パーティクルの座標はフレーム番号から決定論的に計算されるため、レンダリングのたびに同じ結果が得られます(Math.random()は使いません)。
セクションごとにバックグラウンドカラーを変える場合は、セクション開始フレームに応じてinterpolateでカラーを遷移させます。
const bgColors = [
"#1a1a2e", // Aメロ
"#0f3460", // サビ
"#1a1a2e", // 2番
"#4a0f3c", // ブリッジ
];
const sectionDuration = Math.floor(durationInFrames / bgColors.length);
const colorIndex = Math.min(
Math.floor(frame / sectionDuration),
bgColors.length - 1
);
BGMの追加:
Remotionの<Audio>コンポーネントを使えば、音楽ファイルをコンポジションに組み込めます。プレビュー時も再生され、最終レンダリングではFFmpegが映像と音声を合成します。
import { Audio, AbsoluteFill } from "remotion";
export const LyricVideo: React.FC<{
audioSrc: string;
lyrics: LyricLine[];
}> = ({ audioSrc, lyrics }) => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill>
<Audio src={audioSrc} />
<ParticleField />
<LyricTrack lyrics={lyrics} />
</AbsoluteFill>
);
};
フェードインを付ける場合はvolumeプロップスに関数を渡します。
<Audio
src={audioSrc}
volume={(f) =>
interpolate(f, [0, 30], [0, 1], { extrapolateRight: "clamp" })
}
/>
曲の末尾でフェードアウトさせる場合も同様に、最終フレームに向けてボリュームを0に落とすことができます。
1080pと4Kでのレンダリング
YouTubeへの投稿には1920×1080(30fps)が標準です。高解像度配信や映像展示向けに4K(3840×2160、24fps or 30fps)が必要な場合も、コンポジションのwidthとheightを変えるだけです。
<Composition
id="LyricVideo-1080p"
component={LyricVideo}
durationInFrames={totalFrames}
fps={30}
width={1920}
height={1080}
/>
<Composition
id="LyricVideo-4K"
component={LyricVideo}
durationInFrames={totalFrames}
fps={30}
width={3840}
height={2160}
/>
コンポーネント内ですべてのサイズをscale = Math.min(width, height)の倍数で表現しておけば、4Kでは単純に高解像度で描画されます。4Kレンダリングは1080pの約4倍の時間がかかります。ローカルでの確認は1080pで行い、最終出力をRemotionLambdaにオフロードするのが実用的なパターンです。
アルバム全曲の一括レンダリング
Remotionを使うことで実現できる最大の効率化が、バッチレンダリングです。12曲のアルバムを一晩で自動処理できます。
// render-album.ts
import { renderMedia, selectComposition, bundle } from "@remotion/renderer";
import tracks from "./album-tracks.json";
import path from "path";
const bundleUrl = await bundle({
entryPoint: path.join(process.cwd(), "src/index.ts"),
});
for (const track of tracks) {
console.log(`Rendering: ${track.title}...`);
const composition = await selectComposition({
serveUrl: bundleUrl,
id: "LyricVideo-1080p",
inputProps: {
audioSrc: track.audioFile,
lyrics: track.lyrics,
},
});
await renderMedia({
composition,
serveUrl: bundleUrl,
codec: "h264",
outputLocation: `out/${track.slug}.mp4`,
inputProps: {
audioSrc: track.audioFile,
lyrics: track.lyrics,
},
});
console.log(`Done: ${track.slug}.mp4`);
}
album-tracks.jsonに各楽曲のタイトル、スラッグ、音声ファイルパス、歌詞JSONを定義します。スクリプトが順番にレンダリングしてout/ディレクトリに書き出します。歌詞JSONを更新するたびに実行し直すだけで全曲が最新の状態に保たれます。
YouTube向け出力フォーマットのベストプラクティス
リリックビデオをYouTubeにアップロードする場合、いくつかの技術的な点に注意が必要です。
コーデック: H.264(AACオーディオ)が最も互換性が高く、Remotionのデフォルトです。
ビットレート: 1080pのリリックビデオには8〜12 Mbpsが適切です。videoBitrate: "10M"または--crf=18を使用します。
コンテナ: MP4(.mp4)はYouTubeが最も速く処理します。MOVは技術的には対応していますが、MP4のほうが無難です。
オーディオ: 44.1kHz or 48kHz、ステレオAAC、192kbps以上。音楽ファイルが48kHz(音楽制作では標準)の場合、FFmpegが自動的に処理します。
エンドカード用スペース: YouTubeはラスト20秒にエンドカードを重ねるため、その時間帯はシンプルなビジュアル(アルバムアートや曲名のみ)にしておくと視認性が高まります。
RenderCompのリリックビデオテンプレート
パーティクルバックグラウンド、単語ハイライト、セクション別カラーパレット、inputPropsスキーマを備えたリリックビデオテンプレートをRenderCompで提供しています。単曲の制作にも、制作サービスとしての量産にも活用できます。rendercomp.comでテンプレートをご確認ください。
よくある質問
Q1: 音声ファイルから単語レベルのタイムスタンプを自動生成するには?
最もアクセスしやすい方法はOpenAI Whisper APIのtimestamp_granularities: ["word"]オプションです。ローカルで処理する場合はWhisperX(Python)を使うと、より精度の高い強制アライメントが可能です。歌詞が既知の楽曲では強制アライメントのほうが単純な音声認識より精度が高くなります。
Q2: Remotionは長い音声ファイルを扱えますか? はい。4分の楽曲も問題ありません。音声ファイルはレンダリング時にブラウザコンテキストでロードされます。10分を超えるファイルの場合はメモリ使用量に注意してください。
Q3: プレビューでは音楽が再生されるのに、レンダリング後の動画で音がズレます。なぜですか?
多くの場合、タイムスタンプをフレームに変換する際のFPS値の不一致が原因です。useVideoConfig()から取得したfpsを使っているか確認してください。ハードコードした定数(例:30)を使っている場合、コンポジションの実際のFPSと異なると微妙なズレが生じます。
Q4: 音楽のビートに合わせてビジュアルをリアクティブにできますか?
RemotionのデフォルトAPIでは音声波形のリアルタイム解析はできません。推奨パターンは事前処理です:EssentiaやMeydaなどのライブラリで楽曲を解析し、フレームごとの振幅データをJSONとして書き出します。RemotionコンポーネントではuseCurrentFrame()でこのJSONのインデックスを参照して、振幅値でビジュアルを制御します。
Q5: リリックビデオに独自フォントを使いたい場合は?
ローカルの@font-face宣言をRemotionのバンドルに含めることで対応できます。外部CDN(Google Fonts等)からのフォント読み込みはレンダリング環境でのネットワーク依存リスクがあるため避けてください。システムフォントであれば追加設定なしで使用できます。
Q6: 日本語の歌詞で文字化けが起きます。対処法は?
レンダリングマシンに適切なフォントがインストールされていない場合に起きます。fontFamilyに"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serifを指定し、Remotion Lambdaを使う場合はカスタムChromeバイナリにフォントが含まれているか確認してください。
Q7: 複数言語の歌詞(英語・日本語対訳など)を表示するには? 歌詞データに複数言語のフィールドを持たせ、コンポーネントに言語プロップスを渡す構造が最も柔軟です。画面上に2行表示する場合は、メインの歌詞の下に小さいフォントで対訳テキストを並べるシンプルな構成が見やすいです。