mcpField
T
text-message-animation

text-message-animation

github.com/iart-aiクリエイティブ・メディア5688 回インストール18 週間前に更新
リポジトリ

text-message-animation について

チャットの台本をRemotionの会話動画にするスキルです。ショート動画制作者向けに、メッセージ配列とフレーム単位のタイミングで、再現可能なレンダリングを支援します。

主な機能

  1. 01メッセージ配列による動画生成
  2. 02吹き出しと入力中表示の段階的表示
  3. 03iMessage、WhatsApp、SMSのスタイル
  4. 04自動スクロール、既読表示、リアクション
  5. 05タイムラインに同期した音声
  6. 06静止画とMP4の検証用スクリプト

活用シーン

  1. 01縦型チャットストーリー動画の制作
  2. 02台本から会話アニメーションを作成
  3. 03反応を順番に見せる動画の制作
  4. 04JSONやCSVから動画を一括生成

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/iart-ai/text-message-video-skills --skill text-message-animation

インストール後、Claude Code で/text-message-animationを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

README.md
references/
bubble-recipes.md
data-driven.md

詳細

テキストメッセージのアニメーション

チャットの台本を、スクロール中の目を引く会話動画に変えます。吹き出しが1つずつ表示され、返信の前には入力中インジケーターが点滅し、送信時に効果音が鳴り、最新のメッセージが画面内に収まるようにスレッドが自動スクロールします。これは、顔出しなしのショート動画で最も視聴維持率が高いフォーマットの1つです。iMessage/WhatsApp/SMSの吹き出しで語られるストーリーを、視聴者は誰かのスマートフォンをのぞき見するように読みます。

使用する場面

  • TikTok、Reels、Shorts向けの「偽のテキストメッセージ」やチャットストーリー動画(縦型9:16)。
  • 台本からiMessage、WhatsApp、または汎用SMSの会話をアニメーション化する場合。
  • スレッドを通じてオチが1メッセージずつ明かされる、返信/コメントへのリアクション動画。
  • 1つのテンプレートとメッセージ配列またはCSVから、多数のチャット動画をまとめて制作する場合。

絶対に守るべき2つのルール

  1. メッセージはリズムをつけて、1つずつ表示します。 視聴維持率を生むのは、小出しにすることです。入力の間を置いてから吹き出しが表示され、次に読むための間を設けます。スレッド全体をフレーム0で一気に表示してはいけません。実際の会話のようなテンポにします(受信した返信の前には入力中インジケーターを表示し、吹き出しごとに読むための短い間を設けます)。
  2. アプリの画面デザインは、ひと目で本物とわかる必要があります。 送信メッセージは右側で、iMessageなら青、SMSなら緑、WhatsAppなら緑です。受信メッセージは左側で、灰色にします。連続する吹き出しの最後にはしっぽを付け、角丸は約18~22pxにし、ステータスバーとヘッダーも正しく表示します。最初の0.5秒でどのアプリか視聴者に伝わらなければ、リアルさが損なわれます。

データモデル — まず台本、次にレンダリング

すべてのデータをメッセージ配列1つにまとめます。ストーリーをデータとして作成し、レンダラーはそれを再生するだけです。これにより、このフォーマットをまとめて制作できます。1つのテンプレート × N本の台本 → N本の動画です。

type Message = {
  from: "me" | "them";        // me = sent (right/blue), them = received (left/gray)
  text: string;
  typingMs?: number;          // show typing indicator this long before the bubble (received only)
  delayMs?: number;           // read-gap AFTER the previous bubble, before this one
  status?: "delivered" | "read";  // receipt under the last sent bubble
  reaction?: "❤️" | "👍" | "😂" | "‼️" | "❓"; // tapback, lands ~400ms after the bubble
};

const thread: Message[] = [
  { from: "them", text: "wait you're WHERE right now", typingMs: 900 },
  { from: "me",   text: "outside your house 🙂", delayMs: 600, status: "read" },
  { from: "them", text: "...", typingMs: 1400, reaction: "❓" },
];

配列からすべてのフレーム時刻を導きます。詳しくは、下記の「タイミング」を参照してください。CSV(from,text,typingMs,delayMs,status)をそのまま取り込めるように、必須フィールドはtextとfromだけにします。

吹き出しのレイアウトと配置

吹き出しは、画面の左右どちらかに寄せた最大幅のピル形状です。送信メッセージは右端に、受信メッセージは左端に寄せます。送信順に縦に積み重ね、同じ送信者の連続したメッセージでは最後の吹き出しにしっぽを付けます。

const BUBBLE: React.CSSProperties = {
  maxWidth: "74%",                // never full-width — leaves the "this is a phone" gutter
  padding: "14px 18px",
  borderRadius: 22,
  fontSize: 34,                   // ~iMessage proportions at 1080px wide
  lineHeight: 1.25,
  wordBreak: "break-word",
};
const SENT: React.CSSProperties = {
  ...BUBBLE, alignSelf: "flex-end", background: "#0A84FF", color: "#fff",      // iMessage blue
};
const RECEIVED: React.CSSProperties = {
  ...BUBBLE, alignSelf: "flex-start", background: "#3B3B3D", color: "#fff",    // dark-mode gray
};

配色の早見表:iMessageの送信メッセージは#0A84FF、ライト背景は#1C1C1Eです。受信メッセージは、ライトモードでは背景が#E9E9EBでテキストが#000、ダークモードでは#3B3B3Dです。SMSの送信メッセージは緑の#37C24Aです。WhatsAppの送信メッセージは#005C4B、受信メッセージは#0B141Aのチャット壁紙上に表示する#202C33で、チェックマークも付けます(灰色=送信済み、青の#53BDEB=既読)。アプリは1つ選び、画面デザインを完全に合わせます。iMessageの画面デザインにWhatsAppのチェックマークを混ぜてはいけません。

時差表示と入力中インジケーター

各吹き出しは、少し下から小さくポップしながら、独自の表示フレームを基点として現れます。受信メッセージの前には、3つの点が点滅する入力中の吹き出しを表示し、その後、本物のメッセージに置き換えます。

import { useCurrentFrame, useVideoConfig, spring, interpolate, Sequence } from "remotion";

const Bubble: React.FC<{ enterFrame: number; sent: boolean; children: React.ReactNode }> =
({ enterFrame, sent, children }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const p = spring({ frame: frame - enterFrame, fps, config: { damping: 14, mass: 0.7 } });
  return (
    <div style={{
      alignSelf: sent ? "flex-end" : "flex-start",
      transform: `translateY(${interpolate(p, [0, 1], [24, 0])}px) scale(${interpolate(p, [0, 1], [0.85, 1])})`,
      opacity: interpolate(p, [0, 1], [0, 1]),
      transformOrigin: sent ? "bottom right" : "bottom left",
    }}>{children}</div>
  );
};

// three-dot typing indicator — dots breathe on a sine, frame-driven (no CSS animation timers)
const Typing: React.FC = () => {
  const frame = useCurrentFrame();
  const dot = (i: number) => 0.4 + 0.6 * (0.5 + 0.5 * Math.sin((frame / 8) - i * 0.9));
  return (
    <div style={{ ...RECEIVED, display: "flex", gap: 8, padding: "18px 20px" }}>
      {[0, 1, 2].map(i => (
        <span key={i} style={{ width: 12, height: 12, borderRadius: 6, background: "#aaa", opacity: dot(i) }} />
      ))}
    </div>
  );
};

入力中インジケーターは、吹き出しの<Sequence>の直前に<Sequence from={typingStart} durationInFrames={typingLen}>として配置します。先に表示されてから、本物の吹き出しに切り替わります。入力中の表示を行うのは受信メッセージだけです。送信メッセージはそのままポップ表示します(自分が入力する様子を見ることはありません)。

タイミング — 配列をフレームに変換する

メッセージを順にたどり、カーソルにミリ秒単位の時間を加算します。これを唯一の基準として、スプリングによる表示、入力中の時間、効果音、自動スクロールのタイミングを決めます。

const f = (ms: number) => Math.round((ms / 1000) * fps);
let cursor = 0;
const timeline = thread.map((m) => {
  cursor += m.delayMs ?? 500;                          // read-gap before this message
  const typingLen = m.from === "them" ? (m.typingMs ?? 800) : 0;
  const typingStart = cursor;
  cursor += typingLen;                                 // typing plays during the gap
  const enterMs = cursor;                              // bubble pops the instant typing ends
  cursor += 250;                                       // settle before the next read-gap
  return { ...m, typingStartMs: typingStart, typingLenMs: typingLen, enterMs };
});
const durationMs = cursor + 1000;                      // tail pad so the last bubble is readable

テンポを調整します。キビキビした勢いのあるスレッドでは、delayMsを300~500、typingMsを500~900にします。サスペンスを持たせてオチを明かす場合は、決めの一言の前のtypingMsを1500~2500に延ばします。calculateMetadataでdurationMsからdurationInFramesを計算すれば、台本が長くなると動画も自動的に長くなります。

既読通知、タイムスタンプ、リアクション

  • 通知(「配信済み」/「既読」)は、最後の 送信 吹き出しの下に小さく右寄せで表示します。吹き出しが表示されてから約300ms後にフェードインさせます。WhatsAppでは、代わりにダブルチェックが青に変わります。
  • タイムスタンプ(「今日 9:41 PM」)は、新しい時間帯の最初のメッセージの上に、中央揃えのグレーの区切りとして表示します。吹き出しごとにタイムスタンプを付けないでください。
  • リアクション/タップバックは、吹き出しが表示されてから約400ms後に、対象の吹き出しの角に重なる位置から少しオーバーシュートさせながら拡大表示します。グローバルフレームではなく、その吹き出しの enterMs + 400 を基準に配置します。

自動スクロール — 最新の吹き出しを画面内に表示する

吹き出しの積み重ねが表示領域を超えたら、スレッド全体を上に移動させ、最新の吹き出しが下から3分の1の位置にくるようにします。メッセージが表示されるたびに、スプリングアニメーションでスクロールオフセットを目標値へ移動させます。急に飛ぶのではなく、滑らかに動かしてください。

// targetY = total height of messages above the viewport floor, recomputed as bubbles enter
const scroll = spring({ frame: frame - lastEnterFrame, fps, config: { damping: 200 } });
const y = prevY + (targetY - prevY) * scroll;   // ease from old offset to new
return <div style={{ transform: `translateY(${-y}px)` }}>{/* bubbles */}</div>;

通知や入力中のドットがその下に表示されるよう、最新の吹き出しが下部のセーフエリアに重ならないようにしてください(下記参照)。

サウンド同期(メッセージごとのポップ音)

メッセージを送信するたびに軽快な「シュッ/ポン」という音、受信するたびに控えめな「チン」という音を鳴らすことが、この形式をリアルに感じさせる理由の半分を占めています。各メッセージの enterMs に、ビジュアルと同じ時間軸で音声キューを1つ配置します。

import { Audio, staticFile, Sequence } from "remotion";
{timeline.map((m, i) => (
  <Sequence key={i} from={f(m.enterMs)}>
    <Audio src={staticFile(m.from === "me" ? "send.mp3" : "receive.mp3")} />
  </Sequence>
))}

必要に応じて、入力中の各ウィンドウの下に小さなキーボード音を追加します。早いテンポのスレッドで音が重ならないよう、音声キューは短くしてください(300ms未満)。タイマーで音を鳴らさないでください。ヘッドレスレンダリングでも維持できるよう、フレームのタイムラインに沿ってスケジュールします。

9:16(1080×1920)のセーフエリア

領域空けておく範囲理由
上部のステータスバー約120pxここには偽のステータスバー/ノッチが配置されます。吹き出しをその下に置かないでください
アプリヘッダー約150px(アバター+名前)連絡先ヘッダーがアプリらしさを演出します。固定し、ほかの要素で隠さないでください
下部の入力欄+UI約360px偽の入力バーとプラットフォームのキャプション/CTA/音声UIの両方が下部に重なるためです
左右の余白幅の中央約88%吹き出しは画面端に触れません。この余白が「スマートフォンらしさ」を生み出します

ヘッダーと入力バーの間の帯状の領域で会話を進めます。新しい吹き出しをその領域の下から3分の1の位置(入力欄の上)に表示してから、自動スクロールさせます。視線は最新のメッセージをそこに探すためです。

出力チェックリスト

  • すべてを1つのメッセージ配列で制御し、タイムスタンプは手動で配置せず、導出する。
  • メッセージを1つずつ、調整したリズムで表示する:既読間隔 → 入力中(受信のみ)→ ポップ。
  • アプリの外観を最初から最後まで正しく再現する(色、吹き出しのしっぽ、ヘッダー、通知)。複数のアプリを混在させない。
  • 送信側は右側/青または緑、受信側は左側/グレーに配置し、連続する吹き出しの最後にしっぽを付ける。
  • 最後の送信吹き出しの下に通知を表示する。時間帯ごとにタイムスタンプの区切りを表示し、タップバックはオーバーシュートさせる。
  • 自動スクロールで最新の吹き出しを下から3分の1の位置に表示し、セーフエリアに重ならないようにする。
  • メッセージごとにポップ音/チンという音を1回鳴らし、タイマーではなくフレームのタイムラインに沿ってスケジュールする。
  • ヘッダーをステータスバーの下に固定し、会話を9:16のセーフエリア内に表示する。

配信と検証(レンダリングした静止画 → MP4)

同梱ヘルパー(scripts/):scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png で静止画をタイル状に並べ、scripts/probe-mp4.sh out.mp4 [WxH] [fps] でエンコードを検証します。scripts/README.md を参照してください。

Remotion のコンポジション(<Composition> + zod の schema + defaultProps)として提供され、props はメッセージ配列です。吹き出しの表示、タイピングのパルス、スクロールオフセット、サウンドの合図はすべて useCurrentFrame() に基づいてフレーム駆動されます。Date.now()/Math.random()/CSS アニメーションのタイマーは使わないため、ヘッドレスでもどのフレームも同じようにレンダリングされます。成果物は out/*.mp4 +プロジェクトです(新しいスクリプトで再レンダリングできます)。デフォルトは縦型の 9:16(1080×1920)です。長さはデータに応じて変わるため、手動で設定せず、タイムラインカーソルから calculateMetadata 内で durationInFrames を計算してください。

検証ループ — 静止画をレンダリング → 確認 → エンコード。 問題になりやすいのは表示タイミングとスクロール位置です。エンコードに時間をかける前に、正確なフレームを確認してください。

# Stills WITH SHIPPED PROPS (the real thread) at: a typing beat / a mid pop / the last bubble
npx remotion still TextThread out/f-typing.png --frame=24  --props='{"threadSrc":"thread.json"}'
npx remotion still TextThread out/f-mid.png    --frame=90  --props='{"threadSrc":"thread.json"}'
npx remotion still TextThread out/f-end.png    --frame=N   --props='{"threadSrc":"thread.json"}'  # N = durationInFrames-1

# Inspect each PNG:
#  - frame 24 shows the three-dot typing indicator on the LEFT (received), no real bubble yet
#  - at the mid frame the just-landed bubble is on the correct side/color with its tail; receipt/reaction placed right
#  - thread is auto-scrolled so the newest bubble sits in the lower third, NOT hidden under the compose bar
#  - app chrome consistent (status bar, header) and inside the 9:16 safe band; nothing under the top notch

npx remotion render TextThread out/thread.mp4 --props='{"threadSrc":"thread.json"}'   # encode once stills are right
npx remotion render TextThread out/demo.gif --codec=gif                               # README proof clip

npx remotion compositions を使って durationInFrames/fps を確認し、タイピング中・中盤・最後のフレームを選びます。ビルド不要の簡易プレビューとして、同じレイアウトを ?t=N シーク用ハーネス付きのスタンドアロン HTML ページとしてレンダリングできます。references/bubble-recipes.md を参照してください。

完了前に確認すること:

  1. タイピング中のフレーム、中盤の表示、最後の吹き出しで、静止画が問題なくレンダリングされること(フォント/音声が欠落していないこと)。
  2. タイピング中のフレームでは、受信側にインジケーターが表示され(吹き出しではない)、その後に吹き出しが表示されること。
  3. 各吹き出しの側と色が正しく、しっぽも適切であること。開封通知/タイムスタンプ/リアクションが想定どおりの位置に表示されること。
  4. 確認したすべてのフレームで、自動スクロールにより最新の吹き出しが下部 3 分の 1 に収まり、9:16 のセーフエリア内に表示されること。
  5. フレーム駆動のみであること(Date.now()/Math.random()/タイマーを使わないこと)。同梱の props が正しくレンダリングされ、MP4 +任意の GIF が出力されること。

参照ファイル

  • references/bubble-recipes.md — そのまま使えるビルド例:iMessage/WhatsApp/SMS のクロームと配色、メッセージのデータモデルからフレームタイムラインへの変換、スプリング式の吹き出し+しっぽ、3 点タイピングインジケーター、開封通知/タイムスタンプ/タップバック、自動スクロール、メッセージごとのサウンドスケジュール設定、calculateMetadata を使った Remotion コンポジション全体、および ?t=N シーク用ハーネス付きのスタンドアロン HTML プレビュー。
  • references/data-driven.md — スクリプトからメッセージへのデータ形式、JSON/CSV によるスレッドの作成、CSV から Message[] 型へのマッピング、@remotion/renderer を使って 1 つのテンプレートから複数の動画を一括レンダリングする方法。

コメント