Remotionでネットショップの商品動画を自動生成する方法
Remotionでネットショップの商品動画を自動生成する方法
ECサイト運営者にとって、商品動画は売上に直結するコンテンツだ。InstagramやTikTokのアルゴリズムが動画を静止画より優遇していることを踏まえると、商品ごとに個別動画を用意することの重要性はますます高まっている。しかし現実的な問題がある。数百・数千のSKUを持つブランドが、一つひとつの商品に対して動画制作コストをかけることは現実的ではない。
Remotionはこの問題を根本から変える。RemotionはReactでビデオを書くライブラリだ。コンポジションはReactコンポーネントであり、propsとしてデータを受け取る。つまり商品カタログのJSONをそのままpropsとして渡せば、同じテンプレートから全SKU分の動画を自動生成できる。
この記事では、ShopifyのAPIからデータを取得し、Remotionのテンプレートに渡して1000件の商品動画をバッチレンダリングするまでの完全なパイプラインを解説する。
設計の全体像
商品動画自動生成のパイプラインは、大きく3つのステップに分かれる。
データ取得 → ShopifyのREST APIまたはGraphQL APIから商品データをJSONとして取得し、Remotionが扱いやすい形式に正規化する。
テンプレート制作 → 商品名・価格・画像・カテゴリカラーなどのpropsを受け取り、それらをアニメーションとして表示するRemotionコンポジションを1つ作る。
バッチレンダリング → @remotion/rendererのrenderMedia()関数を全商品分ループ呼び出しし、SKUごとにMP4ファイルを出力する。
どのステップも既存のWebフロントエンドの知識で扱える技術だ。特殊なビデオ編集ソフトは一切不要で、すべてTypeScriptとReactで完結する。
商品データの型定義
Remotionテンプレートが受け取るデータ型を最初に設計する。これはテンプレートとデータパイプラインの契約書になる。
// types/Product.ts
export interface Product {
sku: string;
name: string;
brand: string;
price: number;
originalPrice?: number; // セール時の旧価格
currency: string; // "JPY", "USD" など
imageUrl: string;
category: 'apparel' | 'footwear' | 'accessories' | 'electronics' | 'home';
isFlashSale: boolean;
}
ShopifyのAPIレスポンスはこの形式と一致しないため、正規化関数を用意する。
// data/normalize.ts
import type { Product } from '../types/Product';
export function normalizeShopifyProduct(raw: ShopifyProduct): Product {
const variant = raw.variants[0];
const compareAtPrice = variant.compare_at_price
? parseFloat(variant.compare_at_price)
: undefined;
return {
sku: variant.sku,
name: raw.title,
brand: raw.vendor,
price: parseFloat(variant.price),
originalPrice: compareAtPrice,
currency: 'JPY',
imageUrl: raw.images[0]?.src ?? '',
category: mapProductType(raw.product_type),
isFlashSale: raw.tags.includes('flash-sale'),
};
}
カテゴリ別の背景カラー設計
商品カテゴリによってビジュアルトーンを変えると、動画の多様性が生まれる。カテゴリとカラーパレットのマッピングを用意しよう。
// utils/categoryColors.ts
type ColorPalette = { bg: string; accent: string; text: string };
export const categoryColors: Record<string, ColorPalette> = {
apparel: { bg: '#1a1a2e', accent: '#e94560', text: '#ffffff' },
footwear: { bg: '#16213e', accent: '#0f3460', text: '#ffffff' },
accessories: { bg: '#2d132c', accent: '#ee4540', text: '#ffffff' },
electronics: { bg: '#0a3d62', accent: '#60a3bc', text: '#ffffff' },
home: { bg: '#2c3e50', accent: '#27ae60', text: '#ffffff' },
};
メインコンポジション
テンプレートの中心となるコンポジションを作る。商品画像のスプリングアニメーション・商品名のスライドイン・価格表示・フラッシュセールカウントダウンを、それぞれ<Sequence>で時間軸に配置する。
// compositions/ProductVideo.tsx
import {
AbsoluteFill,
Sequence,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
Img,
} from 'remotion';
import type { Product } from '../types/Product';
import { categoryColors } from '../utils/categoryColors';
export const ProductVideo: React.FC<Product> = (product) => {
const { category, name, imageUrl, isFlashSale } = product;
const palette = categoryColors[category] ?? categoryColors.apparel;
return (
<AbsoluteFill style={{ background: palette.bg }}>
{/* 商品画像 — スプリングでスケールイン */}
<Sequence from={0} durationInFrames={Infinity} name="ProductImage">
<ProductImageReveal imageUrl={imageUrl} accent={palette.accent} />
</Sequence>
{/* 商品名 — 下からスライドイン */}
<Sequence from={15} durationInFrames={Infinity} name="ProductName">
<ProductNameEntry name={name} palette={palette} />
</Sequence>
{/* 価格表示 */}
<Sequence from={25} durationInFrames={Infinity} name="Price">
<PriceDisplay product={product} palette={palette} />
</Sequence>
{/* フラッシュセールカウントダウン(条件付き) */}
{isFlashSale && (
<Sequence from={35} durationInFrames={Infinity} name="FlashSale">
<FlashSaleCountdown product={product} accent={palette.accent} />
</Sequence>
)}
</AbsoluteFill>
);
};
商品画像のアニメーション
const ProductImageReveal: React.FC<{ imageUrl: string; accent: string }> = ({
imageUrl,
accent,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scale = spring({
frame,
fps,
from: 0.85,
to: 1,
config: { mass: 1, stiffness: 80, damping: 14, overshootClamping: true },
});
const opacity = interpolate(frame, [0, 12], [0, 1], {
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: 200,
}}
>
<div
style={{
width: 480,
height: 480,
borderRadius: 24,
overflow: 'hidden',
transform: `scale(${scale})`,
opacity,
boxShadow: `0 32px 80px ${accent}55`,
}}
>
<Img
src={imageUrl}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</div>
</AbsoluteFill>
);
};
フラッシュセールのカウントダウンアニメーション
通常の価格表示に加え、フラッシュセール中の商品には残り時間カウントダウンを重ねる。動画の中でカウントダウンが減っていくことで、購買衝動を高める効果がある。
const FlashSaleCountdown: React.FC<{
product: Product;
accent: string;
}> = ({ product, accent }) => {
const frame = useCurrentFrame();
const { fps, durationInFrames } = useVideoConfig();
// 動画の進行に合わせてカウントダウンを変化させる(サンプル実装)
const totalSeconds = 3600;
const elapsed = Math.floor((frame / durationInFrames) * totalSeconds * 0.1);
const remaining = totalSeconds - elapsed;
const hours = Math.floor(remaining / 3600);
const minutes = Math.floor((remaining % 3600) / 60);
const seconds = remaining % 60;
const enterProgress = spring({
frame,
fps,
config: { damping: 12, stiffness: 100, overshootClamping: true },
});
const pulseScale = 1 + interpolate(
Math.sin((frame / fps) * Math.PI * 2),
[-1, 1],
[-0.015, 0.015]
);
return (
<AbsoluteFill
style={{
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
paddingBottom: 40,
}}
>
<div
style={{
background: accent,
borderRadius: 12,
padding: '12px 32px',
opacity: enterProgress,
transform: `scale(${pulseScale}) translateY(${interpolate(
enterProgress,
[0, 1],
[20, 0]
)}px)`,
}}
>
<div
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 13,
fontWeight: 600,
color: 'rgba(255,255,255,0.85)',
textAlign: 'center',
marginBottom: 4,
letterSpacing: '0.05em',
}}
>
セール終了まで
</div>
<div
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 32,
fontWeight: 800,
color: '#ffffff',
letterSpacing: '0.08em',
textAlign: 'center',
}}
>
{String(hours).padStart(2, '0')}:{String(minutes).padStart(2, '0')}:
{String(seconds).padStart(2, '0')}
</div>
</div>
</AbsoluteFill>
);
};
Shopify APIからのデータ取得
Shopifyの管理REST APIを使って全商品データを一括取得する実装例を示す。ページネーションに対応しているため、商品数が多いストアでも全件取得できる。
// scripts/fetchShopify.ts
async function fetchAllProducts(
shopDomain: string,
accessToken: string
): Promise<Product[]> {
const results: Product[] = [];
let url = `https://${shopDomain}/admin/api/2024-01/products.json?limit=250`;
while (url) {
const res = await fetch(url, {
headers: { 'X-Shopify-Access-Token': accessToken },
});
const data = await res.json();
results.push(...data.products.map(normalizeShopifyProduct));
const link = res.headers.get('Link') ?? '';
const next = link.match(/<([^>]+)>; rel="next"/);
url = next ? next[1] : '';
}
return results;
}
取得したデータはproducts.jsonとしてRemotionのpublic/ディレクトリに保存するか、バッチレンダリングスクリプトに直接渡す。
1000件のバッチレンダリング
@remotion/rendererのrenderMedia()関数を使い、全商品分の動画を自動生成する。並列実行数をCONCURRENCYで制御することでメモリ枯渇を防ぐ。
// scripts/batchRender.ts
import { renderMedia, selectComposition } from '@remotion/renderer';
import path from 'path';
const BUNDLE_URL = '/path/to/bundle';
const CONCURRENCY = 4;
async function renderProduct(product: Product, index: number, total: number) {
const composition = await selectComposition({
serveUrl: BUNDLE_URL,
id: 'ProductVideo-Square',
inputProps: product,
});
await renderMedia({
composition,
serveUrl: BUNDLE_URL,
codec: 'h264',
outputLocation: path.join('output', `${product.sku}.mp4`),
inputProps: product,
});
console.log(`[${index + 1}/${total}] ${product.sku} 完了`);
}
async function runBatch(products: Product[]) {
for (let i = 0; i < products.length; i += CONCURRENCY) {
const batch = products.slice(i, i + CONCURRENCY);
await Promise.all(batch.map((p, j) => renderProduct(p, i + j, products.length)));
}
console.log('バッチレンダリング完了');
}
1000件以上を高速処理したい場合は、@remotion/lambdaを使ってAWS Lambda上で並列レンダリングするのが効果的だ。Lambda関数を使えば数百の動画を同時並列で処理でき、ローカル実行と比べてレンダリング時間を劇的に短縮できる。
出力フォーマットの目安
| プラットフォーム | 幅 | 高さ | 秒数 | コーデック | 備考 |
|---|---|---|---|---|---|
| Instagram フィード | 1080 | 1080 | 3〜60秒 | H.264 | 正方形が推奨 |
| Instagram Reels | 1080 | 1920 | 3〜90秒 | H.264 | 縦型のみ |
| TikTok | 1080 | 1920 | 3〜60秒 | H.264 | 縦型のみ |
| YouTube Shorts | 1080 | 1920 | 最大60秒 | H.264 | 縦型 |
複数フォーマットに対応するには、Root.tsxに複数の<Composition>を登録し、バッチスクリプトでループすればよい。
まとめ
RemotionでEC商品動画を自動化する流れは明快だ。ShopifyなどのAPIから商品データを取得・正規化し、型付きpropsで受け取るReactコンポジションにデータを渡し、renderMedia()でSKU分だけループレンダリングする。フラッシュセールのカウントダウンやカテゴリ別カラーなど、商品の状態によるビジュアルの分岐もReactの条件分岐で直感的に書ける。
一度テンプレートを作れば、あとはデータを更新するだけで全商品の動画が自動的に最新化される。動画制作の工数を商品の初期テンプレート設計にだけかけ、量産はすべてコードに任せるのがRemotionを使ったEC動画自動化の本質だ。
よくある質問
Q: Remotionのコンポジションは商品画像をリモートURLから読み込めますか?
はい。<Img>コンポーネントはRemotionのレンダリングパイプラインに対応した画像コンポーネントで、HTTPSのURLを直接渡すことができます。本番バッチレンダリングでは、CDNのURLが安定しているか事前に確認するか、ローカルにダウンロードした画像をstaticFile()で参照することを推奨します。
Q: 商品画像のアスペクト比が商品によってバラバラな場合はどうすればよいですか?
固定サイズのコンテナにobjectFit: 'cover'を設定することで、どのアスペクト比の画像でも一定の見た目にクロップされます。記事内のProductImageRevealコンポーネントがその実装例です。
Q: WooCommerceでも同じパイプラインが使えますか?
使えます。WooCommerceにもREST APIがあり、レスポンス形式が異なるだけです。fetchShopify関数とnormalize関数をWooCommerce向けに書き換えれば、Remotion側のテンプレートとバッチスクリプトはまったく同じものが使えます。
Q: 商品の音声説明(ナレーション)も自動付与できますか?
はい。Remotionの<Audio>コンポーネントで音声ファイルを追加できます。カテゴリや商品特性に応じてBGMや音声ファイルを切り替えることも可能です。TTS(テキスト読み上げ)APIで商品名や価格を読み上げた音声を生成し、各商品動画に付与するという構成も技術的に実現できます。
Q: 出力ファイルのサイズが大きすぎる場合の対処法は?
renderMedia()にcrfオプション(Constant Rate Factor)を渡すことでファイルサイズを制御できます。H.264でcrf: 23が汎用的な出発点で、数値を上げるほどファイルサイズが小さくなります(品質は下がります)。SNS向けにはcrf: 23〜28程度が一般的な範囲です。
Q: カタログが更新されるたびに手動でバッチを実行する必要がありますか?
バッチスクリプトをGitHub ActionsやAWS EventBridgeなどのCI/CDパイプラインに組み込めば、商品データが更新されるたびに自動的にレンダリングを実行できます。Shopify webhookをトリガーにする構成も実装可能です。
Q: @remotion/lambdaはどのくらいのコストがかかりますか?
AWS Lambdaの課金はリクエスト数と実行時間に基づきます。30fpsで5秒の動画1本あたりのレンダリングコストは数円未満です。1000本のバッチでも数千円以内に収まるケースがほとんどで、外注制作コストと比較すると圧倒的に低コストです。
RenderCompのテンプレートで開発をスタートする
本記事で紹介したパイプラインのアーキテクチャは、RenderCompのテンプレートライブラリで採用しているEC動画テンプレートの構成そのものだ。型定義済みのコンポジション・カテゴリカラー設計・バッチレンダリングスクリプトがすべてセットになっている。
ゼロから実装する手間を省き、すぐにカスタマイズに集中したい方はrendercomp.comをチェックしてほしい。