R RenderComp
remotion スポーツ 放送グラフィック スタッツ データビジュアライゼーション

Remotionでスポーツ放送グラフィックを作る:スタッツカード・スコア表示・ランキング

Remotionでスポーツ放送グラフィックを作る:スタッツカード・スコア表示・ランキング

スポーツ放送グラフィックには固有のビジュアル文法がある。左下からスライドインする太字のスタッツカード、シーズンランキングのバーが上から順に伸びるアニメーション、選手の顔写真の上に重なるMVPスポットライト演出。これらの映像言語はスポーツファンが直感的に読み解ける「信頼のシグナル」であり、プロ放送と自主制作コンテンツの差を最も如実に分ける要素のひとつだ。

地方スポーツリーグ・eスポーツ組織・スポーツ系コンテンツクリエイターが抱える共通の課題は、放送グラフィックシステムへのアクセスコストだ。テレビ局が使用するグラフィックシステムは初期投資だけで数百万円、年間ライセンス費用に加えてオペレーター人件費がかさむ。クオリティは圧倒的だが、参入障壁が非常に高い。

Remotionはこの状況を変える。Remotionはビデオ合成をReactコンポーネントとして扱う。スタッツJSONをpropsとして渡し、チームカラーをhex値で指定するだけで、放送品質のグラフィックがコードとして再利用可能な形で生成される。


データアーキテクチャ:スタッツをpropsとして設計する

スポーツデータは本質的に構造化されている。選手ごとの個人スタッツ、チームごとの集計値、試合単位の結果データ——これらはすべてJSONとして表現可能であり、Remotionのpropsに直接マッピングできる。

選手スタッツカードのprops型定義はこのように設計する。

type PlayerStatCardProps = {
  player: {
    id: string;
    name: string;
    number: number;
    position: string;
    photoUrl: string;
    team: string;
  };
  stats: {
    primary: { label: string; value: string | number };
    secondary: Array<{ label: string; value: string | number }>;
  };
  team: {
    primaryColor: string;
    secondaryColor: string;
    logoUrl: string;
    name: string;
  };
  animationStyle?: 'slide-in' | 'rise-up' | 'fade-scale';
};

stats.primaryに最も目立たせたい一指標を、stats.secondaryに補足指標の配列を入れる設計にしておくと、同じコンポーネントをサッカーの「ゴール数」、バスケットボールの「得点」、野球の「防御率」と、スポーツ種別を問わず再利用できる。


選手スタッツカード:放送ローワーサードスタイル

ローワーサード(下3分の1)に表示する選手スタッツカードは、試合映像のアクションシーンに重ねて数秒間表示し、選手のパフォーマンスを文脈づける放送グラフィックの定番だ。

import { useCurrentFrame, spring, interpolate, useVideoConfig, Img } from 'remotion';

export const PlayerStatCard: React.FC<PlayerStatCardProps> = ({
  player,
  stats,
  team,
  animationStyle = 'slide-in',
}) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();

  // 登場アニメーション
  const entranceProgress = spring({
    frame,
    fps,
    config: { damping: 14, stiffness: 100, mass: 0.7 },
    durationInFrames: 20,
  });

  // 退場フェードアウト
  const exitOpacity = interpolate(
    frame,
    [durationInFrames - 20, durationInFrames],
    [1, 0],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  const translateX = animationStyle === 'slide-in'
    ? interpolate(entranceProgress, [0, 1], [-400, 0])
    : 0;
  const translateY = animationStyle === 'rise-up'
    ? interpolate(entranceProgress, [0, 1], [60, 0])
    : 0;
  const opacity = interpolate(entranceProgress, [0, 0.3], [0, 1]) * exitOpacity;

  return (
    <div style={{
      position: 'absolute',
      bottom: 80,
      left: 60,
      transform: `translateX(${translateX}px) translateY(${translateY}px)`,
      opacity,
      display: 'flex',
      alignItems: 'stretch',
      borderRadius: 8,
      overflow: 'hidden',
      boxShadow: '0 4px 32px rgba(0,0,0,0.5)',
      fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
    }}>
      {/* チームカラーのアクセントライン */}
      <div style={{ width: 6, backgroundColor: team.primaryColor, flexShrink: 0 }} />

      {/* 選手写真 */}
      <div style={{
        width: 100,
        backgroundColor: team.primaryColor + '22',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        padding: 8,
      }}>
        <Img
          src={player.photoUrl}
          style={{ width: 84, height: 84, borderRadius: '50%', objectFit: 'cover' }}
        />
      </div>

      {/* メインコンテンツ */}
      <div style={{
        backgroundColor: 'rgba(10,10,10,0.92)',
        padding: '14px 24px',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        gap: 4,
        minWidth: 280,
      }}>
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
          <span style={{ fontSize: 26, fontWeight: 700, color: '#FFFFFF' }}>
            {player.name}
          </span>
          <span style={{ fontSize: 14, color: team.primaryColor, fontWeight: 600 }}>
            #{player.number} · {player.position}
          </span>
        </div>
        <div style={{ display: 'flex', gap: 20, marginTop: 4 }}>
          <div>
            <div style={{ fontSize: 34, fontWeight: 800, color: '#FFFFFF' }}>
              {stats.primary.value}
            </div>
            <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)', letterSpacing: '0.08em' }}>
              {stats.primary.label}
            </div>
          </div>
          {stats.secondary.slice(0, 3).map((s) => (
            <div key={s.label}>
              <div style={{ fontSize: 20, fontWeight: 700, color: 'rgba(255,255,255,0.85)' }}>
                {s.value}
              </div>
              <div style={{ fontSize: 10, color: 'rgba(255,255,255,0.5)', letterSpacing: '0.08em' }}>
                {s.label}
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

animationStyleをpropsとして外部から切り替え可能にしておくことで、同じコンポーネントをシチュエーションによって使い分けられる。試合中のインサート映像にはslide-in、ハーフタイムの集計画面にはrise-upという具合だ。


スコアオーバーレイ:常時表示バグ

放送グラフィックの「バグ(bug)」とは、画面の隅に常時表示されるスコアと試合時間のコンパクトな表示要素のことだ。視聴者が試合のどの時点を見ているかを即座に把握できるため、スポーツ放送において欠かせない存在となっている。

export const ScoreOverlay: React.FC<{
  homeTeam: { name: string; shortName: string; score: number; logoUrl: string; primaryColor: string };
  awayTeam: { name: string; shortName: string; score: number; logoUrl: string; primaryColor: string };
  matchTime: string;
  period: string;
}> = ({ homeTeam, awayTeam, matchTime, period }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const entranceOpacity = spring({
    frame,
    fps,
    config: { damping: 20, stiffness: 80 },
    durationInFrames: 18,
  });

  const homeLeading = homeTeam.score > awayTeam.score;
  const awayLeading = awayTeam.score > homeTeam.score;

  const TeamRow = ({ team, isLeading }: { team: typeof homeTeam; isLeading: boolean }) => (
    <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: 10,
      padding: '8px 14px',
      backgroundColor: isLeading ? team.primaryColor : 'rgba(0,0,0,0.75)',
    }}>
      <Img src={team.logoUrl} style={{ width: 28, height: 28, objectFit: 'contain' }} />
      <span style={{
        fontSize: 15,
        fontWeight: 700,
        color: '#FFFFFF',
        fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
        minWidth: 60,
      }}>
        {team.shortName}
      </span>
      <span style={{
        fontSize: 20,
        fontWeight: 800,
        color: '#FFFFFF',
        minWidth: 24,
        textAlign: 'right',
        fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
      }}>
        {team.score}
      </span>
    </div>
  );

  return (
    <div style={{
      position: 'absolute',
      top: 24,
      left: 24,
      opacity: entranceOpacity,
      borderRadius: 6,
      overflow: 'hidden',
      boxShadow: '0 2px 16px rgba(0,0,0,0.6)',
    }}>
      <TeamRow team={homeTeam} isLeading={homeLeading} />
      <div style={{ height: 1, backgroundColor: 'rgba(255,255,255,0.15)' }} />
      <TeamRow team={awayTeam} isLeading={awayLeading} />
      <div style={{
        backgroundColor: 'rgba(0,0,0,0.85)',
        padding: '4px 8px',
        textAlign: 'center',
        fontSize: 11,
        color: 'rgba(255,255,255,0.6)',
        fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
      }}>
        {period} · {matchTime}
      </div>
    </div>
  );
};

リードしているチームの行をteam.primaryColorで塗りつぶすことで、どちらが優勢かを色だけで瞬時に伝えられる。スコアが同点の場合はどちらも暗背景にするとフラットな表現が維持できる。


シーズンランキングバーチャート

順位グラフィックはスポーツコンテンツで最も視覚的インパクトの高い要素のひとつだ。バーが上位から順番に伸びることで、視聴者に自然と順位を上から下へ読ませる設計になる。

export const RankingsBarChart: React.FC<{
  title: string;
  statLabel: string;
  entries: Array<{ name: string; value: number; teamColor: string }>;
  startFrame: number;
}> = ({ title, statLabel, entries, startFrame }) => {
  const frame = useCurrentFrame();
  const sorted = [...entries].sort((a, b) => b.value - a.value).slice(0, 8);
  const maxValue = sorted[0]?.value ?? 1;

  return (
    <div style={{
      width: '100%',
      padding: '40px 48px',
      fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
    }}>
      <div style={{ fontSize: 13, color: 'rgba(255,255,255,0.5)', letterSpacing: '0.1em', marginBottom: 8 }}>
        {statLabel}
      </div>
      <div style={{ fontSize: 34, fontWeight: 800, color: '#FFFFFF', marginBottom: 32 }}>
        {title}
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        {sorted.map((entry, i) => {
          const delay = startFrame + i * 6;
          const barProgress = spring({
            frame: frame - delay,
            fps: 30,
            config: { damping: 18, stiffness: 60, mass: 1 },
          });
          const rowOpacity = interpolate(frame - delay, [0, 12], [0, 1], {
            extrapolateLeft: 'clamp',
            extrapolateRight: 'clamp',
          });

          return (
            <div key={entry.name} style={{ opacity: rowOpacity }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
                <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
                  <span style={{ fontSize: 13, color: 'rgba(255,255,255,0.4)', fontWeight: 700, minWidth: 20 }}>
                    {i + 1}
                  </span>
                  <span style={{ fontSize: 16, color: '#FFFFFF', fontWeight: 600 }}>{entry.name}</span>
                </div>
                <span style={{ fontSize: 16, color: '#FFFFFF', fontWeight: 700 }}>{entry.value}</span>
              </div>
              <div style={{ height: 8, backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: 4, overflow: 'hidden' }}>
                <div style={{
                  width: `${(entry.value / maxValue) * 100 * barProgress}%`,
                  height: '100%',
                  backgroundColor: entry.teamColor,
                  borderRadius: 4,
                }} />
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};

delay = startFrame + i * 6という設計で各行の登場を6フレームずつずらしている。視聴者の視線が自然に上から下へ誘導され、1位から順に読んでもらいやすい。


MVP演出:スポットライトアニメーション

MVPやマン・オブ・ザ・マッチ演出は、選手への「栄誉」を映像的に表現する重要な場面だ。選手写真を中央に大きく配置し、チームカラーのグロー(光源)効果とともに登場させる構成が基本となる。

export const MVPSpotlight: React.FC<{
  player: PlayerStatCardProps['player'];
  award: string;
  statHighlight: string;
  teamColor: string;
}> = ({ player, award, statHighlight, teamColor }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const photoScale = spring({
    frame,
    fps,
    config: { damping: 12, stiffness: 50, mass: 1.2 },
    durationInFrames: 40,
  });

  const glowOpacity = interpolate(frame, [20, 50], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  const textOpacity = interpolate(frame, [35, 55], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <div style={{
      width: '100%',
      height: '100%',
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
    }}>
      {/* グロー背景 */}
      <div style={{
        position: 'absolute',
        width: 400,
        height: 400,
        borderRadius: '50%',
        backgroundColor: teamColor,
        opacity: glowOpacity * 0.25,
        filter: 'blur(80px)',
      }} />

      {/* 選手写真 */}
      <Img
        src={player.photoUrl}
        style={{
          width: 280,
          height: 280,
          borderRadius: '50%',
          objectFit: 'cover',
          border: `4px solid ${teamColor}`,
          transform: `scale(${photoScale})`,
          boxShadow: `0 0 60px ${teamColor}66`,
        }}
      />

      {/* テキスト */}
      <div style={{ opacity: textOpacity, marginTop: 32, textAlign: 'center' }}>
        <div style={{
          fontSize: 13,
          fontWeight: 700,
          color: teamColor,
          letterSpacing: '0.12em',
          marginBottom: 8,
        }}>
          {award}
        </div>
        <div style={{ fontSize: 42, fontWeight: 800, color: '#FFFFFF', letterSpacing: '-0.01em' }}>
          {player.name}
        </div>
        <div style={{ fontSize: 18, color: 'rgba(255,255,255,0.7)', marginTop: 8 }}>
          {statHighlight}
        </div>
      </div>
    </div>
  );
};

グローのblur値(80px)とopacity(0.25)は選手写真に被らないよう調整するのがポイントだ。チームカラーが非常に明るい(白・黄色系)場合はopacityを0.15程度まで下げると視認性が保たれる。


チームカラーテーマ:propsで全チームに対応

Remotionの強みのひとつは、チームカラーをpropsとして受け取ることで、単一のテンプレートをすべてのチームに対して使い回せる点だ。テンプレートをチームごとに複製する必要がない。

// 背景グラデーション
const backgroundStyle = {
  background: `linear-gradient(135deg, ${team.primaryColor}ee 0%, ${team.secondaryColor}aa 50%, #0a0a0a 100%)`,
};

// スタッツ数値のグロー
const statGlowStyle = {
  textShadow: `0 0 20px ${team.primaryColor}88, 0 0 40px ${team.primaryColor}44`,
};

白や薄い黄色など明度の高いチームカラーに対しては、テキストの輝度を自動判定して白/黒を切り替えるユーティリティ関数を用意しておく。

function isLightColor(hex: string): boolean {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return 0.299 * r + 0.587 * g + 0.114 * b > 128;
}

const textColor = isLightColor(team.primaryColor) ? '#000000' : '#FFFFFF';

スポーツAPIとの連携パターン

スポーツデータは試合ごとに更新されるため、バッチレンダリングパイプラインとAPIの連携が重要になる。Remotionのレンダリング中にAPIをポーリングするのではなく、レンダリング前にデータを取得して静的propsとして渡す設計が基本だ。

async function fetchStandings(leagueId: string) {
  const res = await fetch(
    `https://api.example.com/leagues/${leagueId}/standings`,
    { headers: { 'X-API-Key': process.env.SPORTS_API_KEY! } }
  );
  return res.json();
}

async function renderStandingsGraphic(leagueId: string) {
  const data = await fetchStandings(leagueId);
  const entries = data.teams.map((t: any) => ({
    name: t.abbreviation,
    value: t.wins,
    teamColor: t.primaryColorHex,
  }));

  await renderMedia({
    composition: await selectComposition({
      serveUrl: BUNDLE_URL,
      id: 'RankingsBarChart',
      inputProps: { title: '2026シーズン勝利数', statLabel: '勝', entries, startFrame: 0 },
    }),
    outputLocation: `./out/standings-${leagueId}.mp4`,
    codec: 'h264',
  });
}

試合終了直後に自動でグラフィックを生成してSNSに投稿する「ポストマッチ自動化パイプライン」をWebhookと組み合わせれば、試合終了から10分以内に視覚的なサマリーグラフィックをSNSに公開できる。


RenderCompスポーツテンプレートで即戦力

このガイドで解説したすべてのコンポーネント——選手スタッツカード、スコアオーバーレイ、ランキングバーチャート、MVPスポットライト、試合プレビュー/レキャップ——を実装したRemotionテンプレートが RenderComp で入手できる。チームカラーのpropsテーマ対応済みで、スポーツAPIデータをそのまま流し込んでレンダリングを開始できる。


FAQ

Q: Remotionのグラフィックは生放送中のリアルタイムオーバーレイとして使えますか? A: Remotionはファイルへの事前レンダリングが前提のツールであり、生放送中のリアルタイムグラフィックシステム(Ross XpressionやChyronなど)の代替にはなりません。ただし試合前のプレビュー、ハーフタイムのスタッツパッケージ、試合終了直後のリキャップ動画など、放送の前後に使うグラフィックには非常に適しています。

Q: 選手の統計データをどのスポーツAPIから取得するのがおすすめですか? A: SportRadar、SportsDB、API-Football(サッカー)、NBA Stats APIなどが広く使われています。多くはREST+JSONで統一されており、本記事のprops設計にそのままマッピングできます。コスト面ではデータプロバイダーによって大きく差があるため、必要な競技・リーグ・更新頻度に応じて選択してください。

Q: スコアが変わった瞬間を動画内で表現(例:得点シーン前後の比較)することはできますか? A: できます。Remotionの<Sequence>コンポーネントを使って特定フレームでスコア表示を切り替えます。得点変化の瞬間に白のフラッシュ(不透明度0→1→0の短いアニメーション)を重ねると放送らしい演出になります。

Q: 1試合分のすべてのグラフィック(スターティングイレブン、前半スタッツ、後半スタッツ、最終スコア)を一括生成できますか? A: 可能です。試合データのJSONを一度取得したら、各グラフィック用のコンポジションIDに対してrenderMedia()を順次呼び出す一括スクリプトを書くだけです。試合ひとつあたり4〜6種類のグラフィックを3〜5分で全量生成できます。

Q: テンプレートを特定のスポーツ(野球・バスケ・eスポーツなど)に最適化する場合、どこを変更すればよいですか? A: stats.primarystats.secondarylabelを変えるだけで大半のスポーツに対応できます。アイコンや背景デザインをカスタマイズしたい場合は、sportTypeプロップスを追加して条件レンダリングで表示要素を切り替えるのが最も保守しやすい設計です。

Q: 日本語のチーム名や選手名で文字化けしませんか? A: Remotionはシステムフォントを参照できます。コンテナのフォントスタックに"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serifを指定すれば、macOS・Linux・Windows環境で日本語が正確にレンダリングされます。

販売中

1,000以上のRemotionテンプレートを一括入手

買い切り(一括払い)・サブスクなし・生涯アップデート無料。TypeScript製。

料金プランを見る →