RemotionとWhisperでTikTok風アニメーション字幕を自動生成する方法
RemotionとWhisperでTikTok風アニメーション字幕を自動生成する方法
話している単語が発話と同時にポンと跳ねて色が変わる——単語単位のアニメーション字幕は、いまやショート動画の標準的な視覚文法です。TikTok、リール、ショートを5分眺めれば、トーク系の動画のほとんどがこのスタイルを使っていることに気づくはずです。
スマホの編集アプリに付属する字幕機能でも1本ずつなら作れます。しかし、クリップを読み込み、文字起こしを待ち、誤認識をスマホのキーボードで直し、決められたスタイルから選んで書き出す——この手作業ループは、本数が増えた瞬間にボトルネックになります。
Remotionなら、この工程全体をコードにできます。ローカルで動くWhisperの文字起こしと組み合わせれば、「音声ファイルを入れると、スタイル済みの字幕付き縦型動画が出てくる」パイプラインが完成します。クラウドの文字起こしサービスは不要、タイポグラフィは完全に自由、そして一度定義した字幕スタイルをすべての動画に適用できます。この記事では、そのパイプラインを最初から最後まで作ります。
単語単位の字幕がショート動画の視聴維持率を上げる理由
ショート動画の多くは音声オフで視聴されます。フィードの自動再生、電車の中、職場——この文脈では字幕はアクセシビリティのおまけではなく、メッセージを届ける主経路です。
単語単位の字幕が静的な字幕ブロックより優れている理由は具体的です。
- 常時マイクロモーションが発生する。 200〜400ミリ秒ごとに新しい視覚イベントが起きるため、視聴者の目に追いかける対象を与え続けられます。ショート動画のフィードが鍛えた刺激のリズムそのものです。
- カラオケ風ハイライトが音と映像を結びつける。 音声オンで見たとき、ハイライトされる単語と発話が一致していると、静止カメラ1カットの動画でも「作り込まれた」印象になります。
- 簡潔さが強制される。 一度に1〜4語しか表示されないため、テンポの悪さが即座に可視化されます。
技術的には、単語単位字幕はキネティックタイポグラフィの近縁です。テキストをアトムに分割し、各アトムに固有のタイミングを与え、独立してアニメーションさせる——この基本パターンは共通です(文字・単語単位のアニメーションパターンはキネティックタイポグラフィ完全ガイドで詳しく解説しています)。違いは、タイミングをスタガー計算式ではなく実際の発話タイムスタンプから取ることです。そこでWhisperの出番です。
Whisperでローカル文字起こし(@remotion/install-whisper-cpp)
Remotionには公式パッケージ @remotion/install-whisper-cpp があり、OpenAIのWhisper音声認識モデルの高速C++移植版である whisper.cpp を、すべて自分のマシン上でダウンロード・実行できます。APIキー不要、アップロード不要、分単位の従量課金もなく、オフラインでも動きます。バッチパイプラインにとっては、消費するリソースがCPU時間だけという予測可能性も大きな利点です。
Remotionプロジェクトにインストールします。
npx remotion add @remotion/install-whisper-cpp
次に、whisper.cppのインストール、モデルのダウンロード、文字起こしを行うNode.jsスクリプトを作ります。重要な前提がひとつ——whisper.cppは16bit・16kHzのWAVファイルを要求するため、先にFFmpegで変換しておきます。
// transcribe.mjs — 実行: node transcribe.mjs
import path from 'path';
import fs from 'fs';
import { execSync } from 'child_process';
import {
installWhisperCpp,
downloadWhisperModel,
transcribe,
toCaptions,
} from '@remotion/install-whisper-cpp';
const whisperPath = path.join(process.cwd(), 'whisper.cpp');
await installWhisperCpp({ to: whisperPath, version: '1.5.5' });
await downloadWhisperModel({ model: 'medium.en', folder: whisperPath });
// Whisperは16bit・16kHzのWAVファイルを要求する
execSync(
'ffmpeg -i public/voiceover.mp3 -ar 16000 public/voiceover.wav -y',
);
const whisperCppOutput = await transcribe({
model: 'medium.en',
whisperPath,
whisperCppVersion: '1.5.5',
inputPath: path.join(process.cwd(), 'public', 'voiceover.wav'),
tokenLevelTimestamps: true,
});
// whisper.cppの出力をRemotionのCaption形式に変換する
const { captions } = toCaptions({ whisperCppOutput });
fs.writeFileSync(
path.join(process.cwd(), 'public', 'voiceover-captions.json'),
JSON.stringify(captions, null, 2),
);
特に重要なオプションは2つです。
tokenLevelTimestamps: trueがこのパイプラインの核心です。whisper.cppの--dtw機能(whisper.cpp 1.5.5以降)で高精度なトークン単位のタイムスタンプが計算されます。これがないとトークンのタイミングが粗すぎて、単語ごとのハイライトには使えません。- モデル選択は速度と精度のトレードオフです。
base.enはスタイル調整の反復に十分な速さ、medium.enは最終出力向けに明らかに高精度です。ただし.en系は英語専用モデルです。日本語音声には多言語モデル(mediumやlarge-v3)を使います(後述の日本語対応セクションで詳しく扱います)。
toCaptions() ヘルパーは、whisper.cppの生出力を整形して Caption オブジェクトのクリーンな配列に変換します。これがRemotionの字幕ユーティリティすべてが理解する共通形式です。
@remotion/captions で字幕データを構造化する
@remotion/captions パッケージは、共通の Caption 型とそれを操作するユーティリティを提供します。
import type { Caption } from '@remotion/captions';
type Caption = {
text: string;
startMs: number;
endMs: number;
timestampMs: number | null;
confidence: number | null;
};
ここで鍵になるのが createTikTokStyleCaptions() です。単語単位のフラットな字幕リストを、画面に同時表示される1〜4語の短いグループ=ページにまとめつつ、各ページ内部には単語ごとのタイミングをトークンとして保持します。combineTokensWithinMilliseconds オプションが主要な調整ノブで、値を下げるとページあたりの単語数が減り(よりアグレッシブな単語送り)、上げると落ち着いた複数語表示になります。
字幕JSONを読み込み、ページを構築し、各ページを <Sequence> で描画するコンポーネントです。
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AbsoluteFill,
Sequence,
staticFile,
useDelayRender,
useVideoConfig,
} from 'remotion';
import { createTikTokStyleCaptions } from '@remotion/captions';
import type { Caption } from '@remotion/captions';
import { CaptionPage } from './CaptionPage';
// 大きいほど1ページの単語数が増え、小さいほど単語送りに近づく
const SWITCH_CAPTIONS_EVERY_MS = 900;
export const Captions: React.FC = () => {
const { fps } = useVideoConfig();
const [captions, setCaptions] = useState<Caption[] | null>(null);
const { delayRender, continueRender, cancelRender } = useDelayRender();
const [handle] = useState(() => delayRender());
const fetchCaptions = useCallback(async () => {
try {
const res = await fetch(staticFile('voiceover-captions.json'));
const data = (await res.json()) as Caption[];
setCaptions(data);
continueRender(handle);
} catch (e) {
cancelRender(e);
}
}, [continueRender, cancelRender, handle]);
useEffect(() => {
fetchCaptions();
}, [fetchCaptions]);
const { pages } = useMemo(() => {
return createTikTokStyleCaptions({
captions: captions ?? [],
combineTokensWithinMilliseconds: SWITCH_CAPTIONS_EVERY_MS,
});
}, [captions]);
if (!captions) {
return null;
}
return (
<AbsoluteFill>
{pages.map((page, index) => {
const nextPage = pages[index + 1] ?? null;
const startFrame = (page.startMs / 1000) * fps;
const endFrame = Math.min(
nextPage ? (nextPage.startMs / 1000) * fps : Infinity,
startFrame + (SWITCH_CAPTIONS_EVERY_MS / 1000) * fps,
);
const durationInFrames = endFrame - startFrame;
if (durationInFrames <= 0) {
return null;
}
return (
<Sequence
key={index}
from={startFrame}
durationInFrames={durationInFrames}
>
<CaptionPage page={page} />
</Sequence>
);
})}
</AbsoluteFill>
);
};
useDelayRender() フックがJSONの読み込み完了までレンダリングを保留するため、字幕が欠けたフレームがキャプチャされることはありません。各ページの <Sequence> は「次のページの開始」か「最大表示時間の経過」のどちらか早い方で終わります。
すでに .srt 形式の字幕ファイルがある場合(編集ソフトからの書き出しや過去のワークフローの資産など)は、Whisperを飛ばして同パッケージの parseSrt() で変換できます。ただしSRTは行単位のタイミングしか持たないため、元データが単語単位で刻まれていない限り、単語ハイライトは近似になります。
単語ハイライト(カラオケ)コンポーネントを作る
各 TikTokPage は tokens 配列を持ち、各トークンには fromMs と toMs——その単語が発話されている時間窓——が入っています。ページコンポーネント内で現在フレームを絶対ミリ秒に変換し、どのトークンがアクティブかを判定します。
import React from 'react';
import {
AbsoluteFill,
spring,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
import type { TikTokPage } from '@remotion/captions';
const HIGHLIGHT_COLOR = '#39E508';
export const CaptionPage: React.FC<{ page: TikTokPage }> = ({ page }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// このSequence内のフレームを絶対ミリ秒に変換する
const absoluteTimeMs = page.startMs + (frame / fps) * 1000;
return (
<AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
<div
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 72,
fontWeight: 800,
textAlign: 'center',
whiteSpace: 'pre',
color: '#ffffff',
textShadow: '0 4px 20px rgba(0, 0, 0, 0.75)',
}}
>
{page.tokens.map((token) => {
const isActive =
token.fromMs <= absoluteTimeMs && token.toMs > absoluteTimeMs;
// このトークンがアクティブになってからの経過フレーム数
const tokenStartFrame =
((token.fromMs - page.startMs) / 1000) * fps;
const pop = spring({
frame: frame - tokenStartFrame,
fps,
config: { mass: 0.5, stiffness: 200, damping: 14 },
});
return (
<span
key={token.fromMs}
style={{
display: 'inline-block',
color: isActive ? HIGHLIGHT_COLOR : '#ffffff',
scale: isActive ? 1 + pop * 0.15 : 1,
}}
>
{token.text}
</span>
);
})}
</div>
</AbsoluteFill>
);
};
このコンポーネントの成否を分けるポイントは3つです。
whiteSpace: 'pre'— 英語のトークンテキストは先頭にスペースを含みます("world"ではなく" world")。preがないとスペースが潰れ、単語同士がくっつきます。display: 'inline-block'— 素のインライン要素にはトランスフォームが効きません。これがないとscaleのポップは何も起こらず、しかもエラーも出ません。- スプリングをトークンの開始時刻に紐づける。
spring()にframe - tokenStartFrameを渡すことで、各単語がアクティブになった瞬間に毎回フレッシュなポップが発生します。ページ先頭から1本のスプリングを走らせるのとは効果がまったく違います。
これでカラオケ効果は完成です。発話中の単語が緑に変わって約15%大きくポップし、次の単語に交代すると静かに戻ります。
縦型動画のスタイリング:セーフゾーン・強調ワード・絵文字
1080×1920の縦型コンポジションでは、字幕の「置き場所」が見た目と同じくらい重要です。プラットフォームのUIがフレームを侵食するからです——上部にはアカウント名とサウンド表記、下部には投稿キャプションとアクションボタン、右端にはエンゲージメント列。TikTok・リール・ショートを横断して通用する実用的なセーフゾーンは次の通りです。
- 字幕は上から約220px以下、下から約320px以上に収める
- 右端は約120px空ける(いいね・コメント・シェア列のため)
- 字幕ブロックのスイートスポットは**フレーム高さの55〜70%**の水平帯——被写体の顔より下、プラットフォームUIより上
import { AbsoluteFill } from 'remotion';
import type { TikTokPage } from '@remotion/captions';
import { CaptionPage } from './CaptionPage';
const EMPHASIS_WORDS = new Set(['無料', '絶対', '今すぐ', '禁止']);
const EMPHASIS_COLOR = '#FFD400';
export const isEmphasis = (tokenText: string): boolean => {
const clean = tokenText.trim().toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
return EMPHASIS_WORDS.has(clean);
};
export const SafeZoneCaptionLayer: React.FC<{ page: TikTokPage }> = ({
page,
}) => {
return (
<AbsoluteFill>
<div
style={{
position: 'absolute',
left: 64,
right: 120, // エンゲージメント列を避ける
top: '58%', // 顔より下、下部UIより上
display: 'flex',
justifyContent: 'center',
}}
>
<CaptionPage page={page} />
</div>
</AbsoluteFill>
);
};
強調ワードはTikTok字幕スタイルのもうひとつの特徴です。特定の単語は、カラオケハイライトとは無関係に常に色付き(黄や赤が定番)で表示されます。強調ワードのセットをプロップとして持ち、上記のようにトークンを正規化してから照合し、マッチには専用の色と強めのポップを与えます。字幕がデータである以上、ルールによる自動強調——数字・否定語・最上級表現——も簡単に足せます。
可読性の目安は、幅1080pxならフォントサイズ64px以上・ウェイト700以上、そして必ずコントラスト装置を入れること。上のコンポーネントのようなソフトな textShadow、角丸の背景チップ、または WebkitTextStroke を付けた複製テキストを背面に重ねる縁取り、のいずれかです。
フォントは、例に挙げたシステムフォントスタックがRemotionのChromiumベースレンダラーで設定ゼロで安定描画されます。ブランドフォントを使いたい場合はセルフホストしてください——.woff2 を public/ に置き、@font-face またはRemotionのローカルフォント読み込みで登録します。レンダリングパイプラインで外部フォントCDNにランタイム依存するのは避けるべきです。レンダリング中のネットワーク不調が、そのままバッチ全体の破損につながります。
絵文字はそのまま動きます。Chromiumがシステム絵文字フォントを描画するため、トークンテキストにインラインで混ぜても、キーワードマップ("お金" → 💰)から付加してもかまいません。ただし1ページ1個まで——絵文字は約2文字分の幅を占め、視線を強く引きます。
日本語・多言語字幕レイアウトの対応
ここまでの説明は英語音声を前提にしていましたが、いくつかの調整でパイプラインはそのまま日本語に使えます。
モデルと言語の指定。 .en 系モデルは英語専用です。日本語には多言語モデル(精度重視なら medium か large-v3)をダウンロードし、transcribe() に language オプションを明示します。
await downloadWhisperModel({ model: 'medium', folder: whisperPath });
const whisperCppOutput = await transcribe({
model: 'medium',
whisperPath,
whisperCppVersion: '1.5.5',
inputPath: wavPath,
tokenLevelTimestamps: true,
language: 'ja', // 自動判定に頼らず明示する
});
language: 'auto' を渡せば自動判定もできます(未指定の場合はwhisper.cpp自体のデフォルトである英語にフォールバックします)。ただしBGMのイントロが長い短尺クリップなどで自動判定は誤ることがあるため、バッチパイプラインでは明示が安全です。
トークンの粒度が英語と違います。 日本語には分かち書きがなく、Whisperはサブワード単位のトークンを出力します——1つの単語が2〜3トークンに分かれ、それぞれが独自のタイムスタンプを持つことがあります。トークンを個別にハイライトすると、チカチカした吃音のような効果になります。有効な対策は2つです。
combineTokensWithinMillisecondsを500〜800程度まで下げて各ページを短いフレーズにし、個別トークンではなくページ全体を発話単位としてハイライトする- または、描画前に隣接トークン間のギャップが約50ms未満のものを1つの表示トークンにマージし、単語に近い粒度でトークン単位ハイライトを維持する
レイアウト。 スペースがないため、ブラウザは単語の途中でも平気で改行します。ページを1行に収まる長さに保つのが基本です——パディング込みの1080pxフレームで72pxなら、おおよそ8〜12文字。トークンが先頭スペースを持たないため、whiteSpace: 'pre' の必須要件も日本語では不要になります。フォントは日本語対応のスタックを使います。ここでもシステムフォントかセルフホストの @font-face です。
fontFamily:
'"Hiragino Kaku Gothic ProN", "Yu Gothic", "Noto Sans JP", sans-serif',
漢字は欧文グリフより視覚密度が高いため、同じ極太ウェイトのまま少し小さめ(60〜66px)にした方が読みやすくなります。カラオケコンポーネント自体に他の変更は不要です——Caption のタイミングはどの言語でもミリ秒です。
バッチパイプライン:音声フォルダから字幕付きショート動画へ
ここからが本題の回収です。ナレーション音声のフォルダを、コマンド1つで字幕付き縦型動画のフォルダに変えます。コンポジションをバッチ対応にする変更は2つです。
1つ目は、JSONをfetchする代わりに字幕をinput propsとして渡すこと。useDelayRender() が不要になり、各レンダリングが完全に自己記述的になります。2つ目は calculateMetadata で各動画の長さを字幕データから算出すること。20秒のナレーションからは自動的に21秒の動画ができます(動的な尺の詳細はcalculateMetadataガイドを参照してください)。
// Root.tsx
import { Composition } from 'remotion';
import type { CalculateMetadataFunction } from 'remotion';
import type { Caption } from '@remotion/captions';
import { CaptionedShort } from './CaptionedShort';
export type CaptionedShortProps = {
captions: Caption[];
audioFile: string;
};
const FPS = 30;
const calculateMetadata: CalculateMetadataFunction<CaptionedShortProps> = ({
props,
}) => {
const lastCaption = props.captions[props.captions.length - 1];
const endMs = lastCaption ? lastCaption.endMs : 0;
return {
// 最後の単語の後に1秒の余白を持たせる
durationInFrames: Math.ceil((endMs / 1000) * FPS) + FPS,
};
};
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="CaptionedShort"
component={CaptionedShort}
fps={FPS}
width={1080}
height={1920}
durationInFrames={300}
defaultProps={{ captions: [], audioFile: 'voiceover.mp3' }}
calculateMetadata={calculateMetadata}
/>
);
};
ドライバースクリプトは各ファイルを文字起こしし、@remotion/renderer でレンダリングします。バンドルは1回、レンダリングは何度でも。
// batch.mjs
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { bundle } from '@remotion/bundler';
import { renderMedia, selectComposition } from '@remotion/renderer';
import { transcribe, toCaptions } from '@remotion/install-whisper-cpp';
const whisperPath = path.join(process.cwd(), 'whisper.cpp');
const audioDir = path.join(process.cwd(), 'public', 'audio');
const files = fs.readdirSync(audioDir).filter((f) => f.endsWith('.mp3'));
const serveUrl = await bundle({
entryPoint: path.join(process.cwd(), 'src', 'index.ts'),
});
for (const file of files) {
const base = file.replace(/\.mp3$/, '');
const wavPath = path.join(audioDir, `${base}.wav`);
execSync(`ffmpeg -i "${path.join(audioDir, file)}" -ar 16000 "${wavPath}" -y`);
// 日本語音声なら model: 'medium' + language: 'ja'(前セクション参照)
const whisperCppOutput = await transcribe({
model: 'medium.en',
whisperPath,
whisperCppVersion: '1.5.5',
inputPath: wavPath,
tokenLevelTimestamps: true,
});
const { captions } = toCaptions({ whisperCppOutput });
const inputProps = { captions, audioFile: `audio/${file}` };
const composition = await selectComposition({
serveUrl,
id: 'CaptionedShort',
inputProps,
});
await renderMedia({
composition,
serveUrl,
codec: 'h264',
outputLocation: path.join(process.cwd(), 'out', `${base}.mp4`),
inputProps,
});
console.log(`Rendered ${base}.mp4`);
}
CaptionedShort の内部では、@remotion/media の <Audio src={staticFile(audioFile)} /> でナレーションを再生し、その上に字幕レイヤーを重ねます。時間がかかるのは文字起こしですが、レンダリング前にファイルごとに1回だけ実行され、結果は音声と並べてJSONとしてキャッシュできます。
ここから先の拡張は積み上げ式です。背景動画レイヤーの追加、コーデックの切り替え、クラウドレンダリングへの移行。SNS投稿まで含めた全体ワークフローに組み込むなら、Instagramリール・TikTok自動化ガイドがフォーマット設計と周辺パイプラインをカバーしています。
縦型SNSキットの字幕対応テンプレート
このパイプラインをゼロから組む場合、文字起こしとページ分割のロジックは半日で書けます。時間がかかるのは磨き込みです。本番品質の字幕テンプレートには、欠けて初めて気づくディテールが蓄積されています——プラットフォーム別のセーフゾーンプリセット、強調ワードのプロップ、縁取りと背景チップのスタイルバリアント、ページ遷移が絶対に重ならないよう調整されたスプリング、多言語レイアウトモード、そしてバッチスクリプトから inputProps を直接受け取れるプロップ設計。
その層を解決するのがテンプレートキットです。RenderComp のライブラリには、字幕システムを組み込み済みの縦型SNSテンプレートが揃っています——単語ハイライトコンポーネント、セーフゾーンレイアウト、強調スタイリングを、編集可能なTypeScriptソースとして提供。WhisperのJSONを流し込むだけで使えます。コレクションは rendercomp.com からどうぞ。
よくある質問(FAQ)
Q: Whisperのモデルはどれを使うべきですか?
字幕スタイルの調整中は base.en(英語)や base(多言語)が高速で、レイアウト作業にはタイミング精度も十分です。最終レンダリングでは medium 系に切り替えると、固有名詞や早口での精度差がはっきり出ます。日本語音声には多言語の medium か large-v3 を使い、language: 'ja' を明示します。
Q: すでにSRT字幕ファイルがあります。Whisperは必要ですか?
不要です。@remotion/captions の parseSrt() で Caption[] に変換し、同じページ分割・描画コンポーネントに流せます。ただしSRTのタイミングは字幕行単位であり単語単位ではないため、元データが単語単位で刻まれていない限り、カラオケハイライトは単語ではなく行の切り替わりになります。
Q: Whisperが固有名詞やブランド名を誤認識します。どう直せばいいですか?
JSONを編集してください。文字起こしの出力はレビュー・修正可能な成果物です。字幕ファイルへの一括置換、またはバッチスクリプト内の辞書ベースの置換パスを入れれば、繰り返し出てくる用語を全動画で一度に修正できます。
Q: 単語単位の字幕はレンダリングを遅くしますか?
実質的には遅くなりません。重い処理は文字起こしで、これはレンダリング開始前に1回だけ実行されます。レンダリング時の字幕は静的なJSONで、各フレームで評価されるのは少数のトークン比較とスプリング計算だけ——フレームキャプチャとエンコードに比べれば誤差です。
Q: Studioでは字幕が表示されるのに、レンダリングした動画に映りません。なぜですか?
ほぼ間違いなく読み込みの競合です。useDelayRender() なしで字幕をfetchしたため、JSONが届く前にフレームがキャプチャされています。この記事のようにレンダリングを保留するか、バッチパイプラインのように inputProps で字幕を渡して問題自体を回避してください。
まとめ
RemotionによるTikTok風字幕パイプラインの全体像です。
@remotion/install-whisper-cppで音声をローカル文字起こしする。tokenLevelTimestamps: trueを必ず有効にし、出力はtoCaptions()で変換する(日本語は多言語モデル +language: 'ja')createTikTokStyleCaptions()で単語をページにグループ化する。テンポはcombineTokensWithinMillisecondsで調整する- 各ページを
<Sequence>で描画し、現在時刻を各トークンのfromMs/toMsと比較してアクティブな単語をハイライトする - トークン開始時刻に紐づけた
spring()のポップ、強調ワードのスタイリング、縦型フレームのセーフゾーン配置を加える - バッチ出力は字幕を
inputPropsで渡し、calculateMetadataで尺を算出し、音声フォルダに対してselectComposition()+renderMedia()をループする
字幕対応の縦型動画テンプレートは RenderComp で公開中 →
RenderCompのライブラリには、単語ハイライト字幕システム・プラットフォーム別セーフゾーンレイアウト・強調ワードスタイリングを組み込んだ縦型SNSテンプレートが揃っています。すべて編集可能なTypeScriptソースで、WhisperのキャプションJSONをそのままプロップとして受け取れる設計です。