Remotionでチャットストーリー動画(LINE風トーク動画)を作る方法
Remotionでチャットストーリー動画(LINE風トーク動画)を作る方法
トーク画面の会話がバブルごとに進んでいく「チャットストーリー動画」——日本では「LINE風トーク動画」としておなじみのフォーマットです。顔も声もカメラも要らず、タイピングインジケーターと通知音だけで視聴者を最後のメッセージまで引っ張る、ショート動画で最も安定して視聴されるジャンルのひとつです。
多くの制作者は「なりきりトーク」系のスマホアプリか、動画編集ソフトの手作業キーフレームで作っています。1本目は問題ありませんが、量産に入ると破綻します。40話目の制作時間が1話目とまったく同じだからです。
Remotionはこの経済性を根本から変えます。チャットストーリーの本質は構造化データ——「送信者・本文・タイミング」を持つメッセージのリストです。会話をJSONで書き、Reactコンポーネントに渡せば、縦型動画が完成品として出力される。40話目の制作時間は「台本を書く時間」だけになります。
この記事では、JSON台本フォーマットからバブルコンポーネント、タイピングインジケーター、効果音、動画尺の自動計算、一括レンダリングまで、制作パイプラインの全体を組み立てます。
なぜチャット動画は顔出し不要チャンネルの定番なのか
このフォーマットが強いのは流行ではなく、構造的な理由によります。
- 撮影要件ゼロ。 出演者なし、収録なし、カメラなし。工程は「書く」と「レンダリングする」だけです。
- リテンション装置が組み込み。 すべてのメッセージがミニクリフハンガーです。特にタイピングインジケーター——3つのドットが揺れている間の「次は何て言うんだろう」は、誰もが実生活で知っている感覚そのもので、最強クラスの離脱防止シグナルです。
- 連載と相性が良い。 良いストーリーは自然に分割でき、「続きは次の動画で」はフォロー転換の定番装置になります。
- 着せ替え自由。 同じ台本をLINE風・iMessage風・架空アプリ風のスキンでレンダリングできます。台本ひとつ、見た目は複数。
問題は制作量です。このジャンルは毎日投稿が珍しくなく、そこがまさに手作業編集よりプログラマティックなパイプラインが勝つ局面です。以降、動画は「編集するタイムライン」ではなく「データのレンダリングターゲット」として扱います。
会話をJSON台本としてモデリングする
まずコンテンツとプレゼンテーションを分離します。会話はJSONファイルに、見た目と動きはReactコンポーネントに。台本を書く人(あるいは下書きを生成するLLM)が触るのはJSONだけです。
// src/types.ts
export type ChatMessage = {
from: 'me' | 'them';
text: string;
typingMs?: number; // このメッセージの前にタイピングインジケーターを表示する時間
readMs?: number; // このメッセージ表示後、次のメッセージが始まるまでの間
};
export type ChatScript = {
contact: string; // チャットヘッダーに表示する相手の名前
messages: ChatMessage[];
};
export type ChatStoryProps = {
script: ChatScript;
};
エピソードは単なるファイルになります。
{
"contact": "Unknown Number",
"messages": [
{ "from": "them", "text": "hey, is this Sarah?", "typingMs": 900, "readMs": 1400 },
{ "from": "me", "text": "no, wrong number. who's this?", "typingMs": 1100, "readMs": 1200 },
{ "from": "them", "text": "weird. this is the number she gave me last night", "typingMs": 1600, "readMs": 1800 },
{ "from": "me", "text": "wait... describe her", "typingMs": 800, "readMs": 2200 }
]
}
ポイントは2つ。タイミングはコンポーネントではなく台本側に持たせること——テンポは「書きの判断」であり、ネタばらし前のドラマチックな間(readMs: 2200)はそれを支える言葉の隣に置くべきです。そして typingMs と readMs はオプショナルで、コンポーネント側がデフォルト値にフォールバックするため、素早く書いた台本でも常識的なリズムでレンダリングされます。
メッセージバブルコンポーネントを作る(iMessage風・LINE風)
バブル本体は、自分の <Sequence> が始まった瞬間にスプリングで飛び込んでくるスタイル付き div です。<Sequence> 内では useCurrentFrame() が0にリセットされるため、スプリングは常にメッセージが「届いた」瞬間から新規に動き出します。
// src/MessageBubble.tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate } from 'remotion';
type BubbleProps = {
text: string;
isMe: boolean;
};
export const MessageBubble: React.FC<BubbleProps> = ({ text, isMe }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const pop = spring({
frame,
fps,
config: { mass: 0.6, stiffness: 180, damping: 14 },
});
const scale = interpolate(pop, [0, 1], [0.6, 1]);
const translateY = interpolate(pop, [0, 1], [16, 0]);
const opacity = interpolate(pop, [0, 1], [0, 1], { extrapolateRight: 'clamp' });
return (
<div
style={{
display: 'flex',
justifyContent: isMe ? 'flex-end' : 'flex-start',
padding: '6px 28px',
}}
>
<div
style={{
maxWidth: '72%',
padding: '16px 22px',
borderRadius: 24,
borderBottomRightRadius: isMe ? 6 : 24,
borderBottomLeftRadius: isMe ? 24 : 6,
fontSize: 32,
lineHeight: 1.35,
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
backgroundColor: isMe ? '#0b84ff' : '#e9e9eb',
color: isMe ? '#ffffff' : '#111111',
transform: `translateY(${translateY}px) scale(${scale})`,
transformOrigin: isMe ? 'bottom right' : 'bottom left',
opacity,
}}
>
{text}
</div>
</div>
);
};
「本物っぽさ」を決めるディテールは3点です。
transformOriginをバブルの「しっぽ」側の角に置く。 実際のメッセージアプリのバブルは中心からではなく、スレッドの端に接する角から膨らみます。送信はbottom right、受信はbottom left。- 左右非対称の角丸。 しっぽ側だけ角丸を潰すのが、チャットバブルであることを伝える最も強い視覚記号です。
- システムフォントスタック。
-apple-systemはmacOSレンダラーでiMessageと同系のフォントに解決され、フォールバックでLinux CI上のレンダリングも安定します。Webフォントのリクエストなし、外部依存なしです。
LINE風の見た目にするには、コンポーネントではなくトークン(テーマ変数)を差し替えます。
const THEMES = {
imessage: {
background: '#ffffff',
meBubble: '#0b84ff', meText: '#ffffff',
themBubble: '#e9e9eb', themText: '#111111',
},
line: {
background: '#7494c0',
meBubble: '#8de055', meText: '#111111',
themBubble: '#ffffff', themText: '#111111',
},
} as const;
テーマをpropsで渡せば、同じ台本を両方のスキンでレンダリングできます。ただしスタイリングは「ピクセル単位のクローン」ではなく「インスパイア」に留めること。バブルの左右・配色・レイアウトといった一般的な文法は再現してよいですが、実在アプリのロゴ・アイコン・UI素材そのもののコピーはNGです。
タイミング設計:タイピングインジケーターとバブルのポップ
会話のリズムは、台本のミリ秒値をフレームオフセットに変換することで生まれます。タイムライン全体をひとつのヘルパー関数で計算し、コンポジションと(後述する)動画尺の両方にとっての単一の情報源にします。
// src/timing.ts
import type { ChatMessage } from './types';
export const getScriptTiming = (messages: ChatMessage[], fps: number) => {
let cursor = 0;
const timings = messages.map((message) => {
const typingFrames = Math.round(((message.typingMs ?? 800) / 1000) * fps);
const readFrames = Math.round(((message.readMs ?? 1400) / 1000) * fps);
const typingStart = cursor;
const popFrame = typingStart + typingFrames;
cursor = popFrame + readFrames;
return { typingStart, typingFrames, popFrame };
});
return { timings, totalFrames: cursor };
};
タイピングインジケーターは、3つのドットがずれた波で脈打つコンポーネントです。すべてフレーム値で駆動します——CSSアニメーションはRemotionで決定論的にレンダリングされないため使いません。
// src/TypingIndicator.tsx
import { useCurrentFrame, interpolate } from 'remotion';
export const TypingIndicator: React.FC = () => {
const frame = useCurrentFrame();
return (
<div style={{ display: 'flex', justifyContent: 'flex-start', padding: '6px 28px' }}>
<div
style={{
display: 'flex',
gap: 8,
padding: '20px 22px',
borderRadius: 24,
borderBottomLeftRadius: 6,
backgroundColor: '#e9e9eb',
}}
>
{[0, 1, 2].map((i) => {
const cycle = (frame + i * 8) % 24;
const lift = interpolate(cycle, [0, 6, 12, 24], [0, -6, 0, 0]);
const opacity = interpolate(cycle, [0, 6, 12, 24], [0.4, 1, 0.4, 0.4]);
return (
<div
key={i}
style={{
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#8e8e93',
transform: `translateY(${lift}px)`,
opacity,
}}
/>
);
})}
</div>
</div>
);
};
これらを組み立ててスレッドにします。各メッセージに2つのシーケンス——タイピングインジケーター(相手のメッセージのみ、バブルがポップする瞬間ちょうどに終了)と、最後までマウントされ続けるバブル本体——を割り当てます。下端アンカーのflexカラムにすることで、「新着メッセージが来るとスレッドが上に押し上げられる」挙動がスクロール計算なしで手に入ります。
// src/ChatStory.tsx
import React from 'react';
import { AbsoluteFill, Sequence, useVideoConfig } from 'remotion';
import { MessageBubble } from './MessageBubble';
import { TypingIndicator } from './TypingIndicator';
import { getScriptTiming } from './timing';
import type { ChatStoryProps } from './types';
export const ChatStory: React.FC<ChatStoryProps> = ({ script }) => {
const { fps } = useVideoConfig();
const { timings } = getScriptTiming(script.messages, fps);
return (
<AbsoluteFill style={{ backgroundColor: '#ffffff' }}>
{/* チャットヘッダー */}
<div
style={{
height: 150,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
paddingBottom: 18,
borderBottom: '1px solid #d1d1d6',
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 34,
fontWeight: 600,
color: '#111111',
}}
>
{script.contact}
</div>
{/* メッセージスレッド(下端アンカー) */}
<AbsoluteFill
style={{
top: 150,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
paddingBottom: 80,
overflow: 'hidden',
}}
>
{script.messages.map((message, i) => {
const t = timings[i];
return (
<React.Fragment key={i}>
{message.from === 'them' && (
<Sequence from={t.typingStart} durationInFrames={t.typingFrames} layout="none">
<TypingIndicator />
</Sequence>
)}
<Sequence from={t.popFrame} layout="none">
<MessageBubble text={message.text} isMe={message.from === 'me'} />
</Sequence>
</React.Fragment>
);
})}
</AbsoluteFill>
</AbsoluteFill>
);
};
ここで layout="none" が重要です。これがないと各 <Sequence> がabsolute fillとして描画され、バブルがカラム内に流れず全部重なってしまいます。「自分」のメッセージに見えるインジケーターはありませんが、typingMs の遅延は同様に適用され、「自分が返信を打っている間」の自然な間として機能します。
バブルのスプリング設定(stiffness: 180, damping: 14)は、わずかなオーバーシュートを伴う素早いポップで、実際のメッセージアプリのエネルギー感に合っています。mass・stiffness・damping のチューニングはspringアニメーション完全ガイドで深掘りしています。
サウンドデザイン:タイピング音・送信音・通知音
チャットストーリーは音アリで視聴される比率が高いフォーマットです。ドットの間のキーボード音、送信時の「シュッ」、受信時の通知音——オーディオがストーリーのリズムセクションを担うからです。Remotionなら、ビジュアルと同じ <Sequence> オフセットで効果音を決定論的に配置できます。
メディアパッケージを追加し、効果音ファイルを public/sfx/ に置きます。
npx remotion add @remotion/media
import { Audio } from '@remotion/media';
import { Sequence, staticFile } from 'remotion';
// <ChatStory> 内、バブルのシーケンスと並べて配置する:
{script.messages.map((message, i) => {
const t = timings[i];
return (
<React.Fragment key={`sfx-${i}`}>
{/* 相手のタイピング中のキーボード音 */}
{message.from === 'them' && (
<Sequence from={t.typingStart} durationInFrames={t.typingFrames}>
<Audio src={staticFile('sfx/keyboard-loop.mp3')} loop volume={0.3} />
</Sequence>
)}
{/* ポップの瞬間に送信音/受信音を鳴らす */}
<Sequence from={t.popFrame}>
<Audio
src={staticFile(message.from === 'me' ? 'sfx/send.mp3' : 'sfx/receive.mp3')}
volume={0.7}
/>
</Sequence>
</React.Fragment>
);
})}
オーディオのシーケンスがバブルと同じ timings 配列を再利用しているため、音と映像がズレることは構造的にあり得ません。JSONの typingMs を変えれば両方が一緒に動きます。効果音はライセンス済みのSFXライブラリから調達するか自分で録音し(スマホのキーボードを近接マイクで録ると意外なほどそれらしく聞こえます)、キーボードループは質感として鳴る程度に控えめに。BGMを敷くなら、コンポジションのルートに volume={0.15} と loop を付けた <Audio> をもう1つ置くだけです。Remotionのオーディオ制御全般はオーディオ完全ガイドにまとめています。
calculateMetadataで動画の長さを台本に自動追従させる
エピソードごとに長さが違うので、durationInFrames のハードコードは論外です。calculateMetadata はレンダリング前に入力propsを受け取り、正しい尺を返します。同じ getScriptTiming ヘルパーを再利用するため、コンポジションとメタデータが食い違うことはありません。
// src/Root.tsx
import { Composition, CalculateMetadataFunction } from 'remotion';
import { ChatStory } from './ChatStory';
import { getScriptTiming } from './timing';
import type { ChatStoryProps } from './types';
import defaultScript from '../stories/wrong-number-part-1.json';
const FPS = 30;
const calculateMetadata: CalculateMetadataFunction<ChatStoryProps> = ({ props }) => {
const { totalFrames } = getScriptTiming(props.script.messages, FPS);
const outroFrames = 2 * FPS; // 最後のメッセージで静止する時間
return {
durationInFrames: totalFrames + outroFrames,
};
};
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="ChatStory"
component={ChatStory}
fps={FPS}
width={1080}
height={1920}
durationInFrames={300} // プレースホルダー。calculateMetadataが上書きする
defaultProps={{ script: defaultScript }}
calculateMetadata={calculateMetadata}
/>
);
};
最後の2秒間のアウトロは飾りではなく、視聴者が最後のメッセージ(たいていクリフハンガー)を咀嚼する時間です。リモートデータの取得や解像度の動的変更など、calculateMetadata のさらに進んだパターンは動的尺調整ガイドを参照してください。
storiesフォルダからエピソードを一括生成する
パイプラインの投資が回収されるのはここです。全エピソードのJSONを stories/ フォルダに放り込み、Node.js APIで一括レンダリングします。selectComposition が各エピソードのpropsで calculateMetadata を実行するため、すべての動画が自動的に正しい尺になります。
// render-all.ts — 実行: npx tsx render-all.ts
import { bundle } from '@remotion/bundler';
import { renderMedia, selectComposition } from '@remotion/renderer';
import fs from 'fs';
import path from 'path';
const storiesDir = path.join(process.cwd(), 'stories');
const episodes = fs.readdirSync(storiesDir).filter((f) => f.endsWith('.json'));
const serveUrl = await bundle({
entryPoint: path.join(process.cwd(), 'src', 'index.ts'),
});
for (const file of episodes) {
const script = JSON.parse(
fs.readFileSync(path.join(storiesDir, file), 'utf-8'),
);
const inputProps = { script };
const composition = await selectComposition({
serveUrl,
id: 'ChatStory',
inputProps,
});
await renderMedia({
composition,
serveUrl,
codec: 'h264',
inputProps,
outputLocation: `out/${path.basename(file, '.json')}.mp4`,
});
console.log(`Rendered ${file} (${composition.durationInFrames} frames)`);
}
日曜日に10話分の台本を書き、コマンドを1回実行すれば、1週間分の投稿が out/ に並びます。ローカルで捌けない量になっても、同じコンポジションが @remotion/lambda でそのまま並列クラウドレンダリングに使えます。生成側を足すならここが接続点です。JSONスキーマに沿った会話をLLMに書かせ、バリデーションして stories/ に落とすスクリプトを挟めば、途中に人間のレビューを置いたコンテンツシステムになります。
縦型で仕上げるための実務ノート
出荷前に押さえるポイントです。
- 1080×1920でレンダリングする。 トーク画面は縦持ちが前提で、このフォーマットは縦でのみ成立します。
- プラットフォームのセーフゾーンを守る。 ショート動画プレイヤーは画面の上下端にUIをオーバーレイします。重要なテキストは画面上部のおよそ12%より下に置き、最後のバブルは下部のアクションバー領域より上に収めます(スレッドコンテナの
paddingBottom: 80がその役割です)。 - 音オフでも成立する。 テキストそのものがコンテンツなので、ミュート視聴でも生き残ります——ナレーション駆動フォーマットに対するもうひとつの構造的優位です。正方形や横型への展開も同じ台本で可能です。投稿ワークフローへの組み込みはInstagram Reels・TikTok自動化を参照してください。
ここまでの内容は半日で組めます。時間がかかるのはその先の磨き込み——既読表示、タイムスタンプ、画像メッセージ、リアクション、リアルなステータスバー、複数スキンなど、スレッドを「図解」ではなく「本物のスマホ」に見せる無数の小さな判断です。
よくある質問(FAQ)
Q: ポップ表示ではなく1文字ずつタイプされるように表示できますか?
できます。バブル内でローカルフレームから部分文字列を導出します: text.slice(0, Math.floor(frame * charsPerFrame))。ただし実際のチャンネルの多くは「タイピングインジケーター→完成メッセージのポップ」方式です。その方が読みが速く、受信時の実際の見え方に一致します。1文字ずつのタイプ表示は「自分」側、つまり返信を打っている演出に向いています。
Q: バブルの中に写真や画像を入れるには?
ChatMessage 型に image フィールドを追加し、パディングを減らしたバブルバリアントの中で <Img src={staticFile('photos/dog.jpg')} /> を使います。画像は public/ に置き、レンダリング時に staticFile() で解決させます。
Q: iMessageやLINEの本物のフォント・UIをそのまま使ってもいいですか?
プラットフォームの専有フォントは同梱せず、上で示したシステムフォントスタックを使い、UI要素は実アプリのスクリーンショットではなく自作コンポーネントとして再構築してください。スタイルやレイアウトの慣習から着想を得るのは問題ありませんが、素材・アイコン・ロゴのコピーはNGです。特定のライセンス済みフォントを使いたい場合は @font-face でセルフホストします——外部CDNからのWebフォント読み込みは、レンダリングをネットワークに依存させるので避けてください。
Q: 1話の長さはどのくらいが適切ですか?
多くの成功例は1パート30〜90秒で、上記のデフォルトペースならおよそ12〜25メッセージです。台本が長くなったら、最も強いクリフハンガーで分割して前後編に——このフォーマットはそれが報われる構造をしています。
Q: なりきりトーク系アプリではダメなのですか?
1本作るだけなら十分です。破綻するのは量産時——バージョン管理なし、一括レンダリングなし、スタイル変更後に全エピソードを再レンダリングする手段なし。Remotionなら、バブルの色やスプリング設定を1箇所変えるだけで、次のレンダリングでバックカタログ全体が更新されます。
まとめ
Remotionのチャットストーリーパイプラインは5つの部品に集約されます。
- 各メッセージが送信者・本文・タイミングを持つJSON台本フォーマット
spring()+interpolate()でしっぽ側の角から飛び込むバブルコンポーネント- ミリ秒をフレームオフセットに変換するタイミングヘルパー——映像・音・尺で共有する
- 下端アンカーのflexカラム内での
<Sequence>オーケストレーションと、同じオフセットに載せる効果音 - エピソードごとの尺を担う
calculateMetadataと、stories/フォルダを丸ごとレンダリングするNodeスクリプト
白紙から作る工程は RenderComp でスキップできます →
RenderCompのライブラリには、本番品質の縦型ソーシャルテンプレートとUIマイクロインタラクションのコンポーネントが揃っています。型付きprops、調整済みのスプリング物理、編集可能なTypeScriptソース。テンプレートをプロジェクトに落として台本データを指すだけで、今日からエピソードのレンダリングを始められます。