Remotionで教育動画・eラーニングコンテンツを制作する方法
Remotionで教育動画・eラーニングコンテンツを制作する方法
eラーニングコンテンツには、他のビデオジャンルにはない特有の課題がある。動画の品質にばらつきがあると、学習者は内容への信頼感を失う。1本だけ高品質な動画を作ることは難しくない。問題は、数十・数百本のレッスン動画を一貫した品質で量産することだ。
Remotionはこの課題に対して特別に相性がよい。教育コンテンツは本質的に繰り返し構造を持っている——タイトルスライド、概念説明スライド、箇条書き、クイズ、まとめ。その構造はプログラマブルビデオが最も得意とするパターンだ。一度テンプレートを作れば、レッスンデータをJSONで渡すだけで統一された品質の動画が自動生成される。
この記事では、レッスンデータの設計・スライドシーケンスの実装・<Audio>によるナレーション同期・クイズスライドの演出・受講証明書の自動生成までを体系的に解説する。
なぜ教育動画にプログラマブルアプローチが向いているか
実装の前に、Remotionが教育コンテンツに特別に適している理由を整理しておきたい。
品質の一貫性。 同じテンプレートから生成される動画は、フォント・余白・アニメーションのタイミングがすべて統一される。学習者はこれを意識しないが、品質にばらつきがある場合には強く違和感を感じる。一貫したビジュアル品質は、コンテンツへの信頼を構築する基盤になる。
更新コストの低さ。 従来の動画は文字情報が映像に焼き込まれているため、内容が変わるたびに再収録・再編集が必要だ。Remotionでは、テキスト内容はJSONやTypeScriptのデータファイルに分離されている。数字や説明文を修正して再レンダリングするだけで、数分で動画を最新化できる。
パーソナライゼーション。 受講者の名前・所属・進捗データを動画の中に埋め込むことが、propsを渡すだけで実現する。「〇〇さん、このコースでは〜を学びます」という個人向けメッセージを、すべての受講者に対して個別動画として生成できる。
バリアント制作の容易さ。 同じテンプレートに異なるpropsを渡すことで、日本語版・英語版、初級者向け・上級者向けといったバリアントを、一切の編集作業なしに生成できる。
レッスンデータの設計
Remotionテンプレートに渡すデータ型を最初に設計することが、eラーニングシステム全体の設計における最重要の意思決定だ。
// types/Lesson.ts
export interface LessonSlide {
type: 'title' | 'concept' | 'quiz' | 'summary';
durationSeconds: number;
heading: string;
body?: string;
bulletPoints?: string[];
quizQuestion?: QuizData;
}
export interface QuizData {
question: string;
options: string[];
correctIndex: number;
explanation: string;
}
export interface Lesson {
id: string;
title: string;
module: string;
instructor: string;
voiceoverUrl?: string;
brandColor: string;
slides: LessonSlide[];
}
レッスンの内容はJSONで定義する。
{
"id": "react-hooks-intro",
"title": "Reactフックス入門",
"module": "Reactの基礎",
"instructor": "Jordan Lee",
"brandColor": "#6366f1",
"slides": [
{
"type": "title",
"durationSeconds": 4,
"heading": "Reactフックス",
"body": "第3章 · Reactの基礎"
},
{
"type": "concept",
"durationSeconds": 9,
"heading": "フックスとは何か",
"body": "フックスを使うと、関数コンポーネントの中でstateやライフサイクルなどのReact機能を使えるようになります。",
"bulletPoints": [
"React 16.8で導入された",
"名前は必ず'use'で始まる",
"条件分岐の中で呼び出せない"
]
},
{
"type": "quiz",
"durationSeconds": 10,
"heading": "確認問題",
"quizQuestion": {
"question": "次のうち、正しいフックス名はどれですか?",
"options": ["fetchData", "useDataFetcher", "DataFetcher", "getState"],
"correctIndex": 1,
"explanation": "フックスは必ず'use'で始める必要があります。これはReactのlinterが強制するルールです。"
}
}
]
}
レッスンコンポジションの実装
メインコンポジションはスライド配列をループして、各スライドを<Series.Sequence>に配置する。<Series>は各スライドの終わりに次のスライドを自動的に続けてくれるため、累積フレームオフセットを手動で計算する必要がない。
// compositions/LessonVideo.tsx
import { AbsoluteFill, Series, Audio, staticFile, useVideoConfig } from 'remotion';
import type { Lesson, LessonSlide } from '../types/Lesson';
import { TitleSlide } from './slides/TitleSlide';
import { ConceptSlide } from './slides/ConceptSlide';
import { QuizSlide } from './slides/QuizSlide';
import { SummarySlide } from './slides/SummarySlide';
export const LessonVideo: React.FC<Lesson> = (lesson) => {
const { fps } = useVideoConfig();
function renderSlide(slide: LessonSlide) {
switch (slide.type) {
case 'title': return <TitleSlide {...slide} brandColor={lesson.brandColor} />;
case 'concept': return <ConceptSlide {...slide} brandColor={lesson.brandColor} />;
case 'quiz': return <QuizSlide {...slide} brandColor={lesson.brandColor} />;
case 'summary': return <SummarySlide {...slide} brandColor={lesson.brandColor} />;
default: return null;
}
}
return (
<AbsoluteFill style={{ background: '#0f172a' }}>
{/* ナレーション音声 — レッスン全体にわたって再生 */}
{lesson.voiceoverUrl && (
<Audio
src={lesson.voiceoverUrl.startsWith('http')
? lesson.voiceoverUrl
: staticFile(lesson.voiceoverUrl)}
volume={1}
/>
)}
<Series>
{lesson.slides.map((slide, i) => (
<Series.Sequence
key={i}
durationInFrames={Math.round(slide.durationSeconds * fps)}
name={`Slide-${i + 1}-${slide.type}`}
>
{renderSlide(slide)}
</Series.Sequence>
))}
</Series>
</AbsoluteFill>
);
};
各スライドコンポーネントはグローバルのタイムラインを知らない。スライドのシーケンスが始まるとそのコンポーネントのローカルフレームは0からスタートし、すべての入場アニメーションが毎回新鮮に走る。
スライドコンポーネントの実装
タイトルスライド
// compositions/slides/TitleSlide.tsx
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
spring,
interpolate,
} from 'remotion';
export const TitleSlide: React.FC<{
heading: string;
body?: string;
brandColor: string;
}> = ({ heading, body, brandColor }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const headlineProgress = spring({
frame,
fps,
config: { mass: 0.9, stiffness: 90, damping: 14 },
});
const bodyProgress = spring({
frame: Math.max(0, frame - 12),
fps,
config: { damping: 16, stiffness: 80, overshootClamping: true },
});
return (
<AbsoluteFill
style={{
background: `linear-gradient(135deg, #0f172a 0%, ${brandColor}22 100%)`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '0 120px',
}}
>
{/* アクセントバー */}
<div
style={{
width: interpolate(headlineProgress, [0, 1], [0, 80]),
height: 4,
background: brandColor,
borderRadius: 2,
marginBottom: 32,
}}
/>
<h1
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 64,
fontWeight: 800,
color: '#f8fafc',
textAlign: 'center',
lineHeight: 1.3,
margin: 0,
opacity: headlineProgress,
transform: `translateY(${interpolate(
headlineProgress,
[0, 1],
[24, 0]
)}px)`,
}}
>
{heading}
</h1>
{body && (
<p
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 24,
color: 'rgba(248, 250, 252, 0.55)',
textAlign: 'center',
marginTop: 24,
opacity: bodyProgress,
}}
>
{body}
</p>
)}
</AbsoluteFill>
);
};
概念スライド(箇条書き付き)
// compositions/slides/ConceptSlide.tsx
import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig, spring, interpolate } from 'remotion';
export const ConceptSlide: React.FC<{
heading: string;
body?: string;
bulletPoints?: string[];
brandColor: string;
}> = ({ heading, body, bulletPoints = [], brandColor }) => {
return (
<AbsoluteFill
style={{
background: '#0f172a',
padding: '80px 120px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{/* 見出し */}
<Sequence from={0} durationInFrames={Infinity} name="Heading" layout="none">
<ConceptHeading text={heading} brandColor={brandColor} />
</Sequence>
{/* 本文 */}
{body && (
<Sequence from={10} durationInFrames={Infinity} name="Body" layout="none">
<ConceptBody text={body} />
</Sequence>
)}
{/* 箇条書き — 時間差で登場 */}
{bulletPoints.map((point, i) => (
<Sequence
key={i}
from={18 + i * 8}
durationInFrames={Infinity}
name={`Bullet-${i}`}
layout="none"
>
<BulletPoint text={point} brandColor={brandColor} />
</Sequence>
))}
</AbsoluteFill>
);
};
const ConceptHeading: React.FC<{ text: string; brandColor: string }> = ({
text, brandColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({ frame, fps, config: { damping: 14, stiffness: 90 } });
return (
<h2
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 48,
fontWeight: 700,
color: '#f8fafc',
borderLeft: `5px solid ${brandColor}`,
paddingLeft: 24,
margin: '0 0 20px',
opacity: progress,
transform: `translateX(${interpolate(progress, [0, 1], [-20, 0])}px)`,
}}
>
{text}
</h2>
);
};
const ConceptBody: React.FC<{ text: string }> = ({ text }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame, fps,
config: { damping: 16, stiffness: 80, overshootClamping: true },
});
return (
<p
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 26,
lineHeight: 1.7,
color: 'rgba(248, 250, 252, 0.75)',
margin: '0 0 28px',
opacity: progress,
}}
>
{text}
</p>
);
};
const BulletPoint: React.FC<{ text: string; brandColor: string }> = ({
text, brandColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame, fps,
config: { mass: 0.8, damping: 14, stiffness: 100 },
});
return (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 16,
marginBottom: 14,
opacity: progress,
transform: `translateX(${interpolate(progress, [0, 1], [-16, 0])}px)`,
}}
>
<div
style={{
width: 10, height: 10,
borderRadius: '50%',
background: brandColor,
marginTop: 10,
flexShrink: 0,
}}
/>
<p
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 24,
color: 'rgba(248, 250, 252, 0.85)',
margin: 0,
lineHeight: 1.6,
}}
>
{text}
</p>
</div>
);
};
<Audio>によるナレーション同期
Remotionの<Audio>コンポーネントを使えば、ナレーション音声とスライドを同期させることができる。音声ファイルをRemotionプロジェクトのpublic/ディレクトリに配置し、staticFile()で参照する。
// ナレーション音声の配置例
public/
audio/
lesson-01-hooks-intro.mp3
lesson-02-usestate.mp3
// 使用例(LessonVideoコンポーネント内)
<Audio src={staticFile('audio/lesson-01-hooks-intro.mp3')} volume={1} />
スライドとナレーションの精密な同期を実現するには、ナレーションの収録時に各スライドの話し始めと話し終わりのタイムコードを記録しておき、そのタイムコードをスライドのdurationSecondsとして設定する。
// ナレーションのタイムコードに基づいてスライド尺を設定
const slides: LessonSlide[] = [
{ type: 'title', heading: 'Reactフックス', durationSeconds: 4.2, /* 0〜4.2秒 */ },
{ type: 'concept', heading: 'フックスとは何か', durationSeconds: 11.8, /* 4.2〜16秒 */ },
{ type: 'concept', heading: 'useState', durationSeconds: 9.5, /* 16〜25.5秒 */ },
];
// 合計 25.5秒 = 収録したナレーション音声の長さと一致させる
クイズスライドの演出
動画の中では実際のインタラクションはできないが、演出として問題→選択肢の順番に登場させ、一定時間後に正解をハイライトする構成が教育効果を高める。学習者が答えを考える間を作り、そのあと正解を見せることで記憶への定着率が上がる。
// compositions/slides/QuizSlide.tsx
import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig, spring, interpolate } from 'remotion';
export const QuizSlide: React.FC<{
quizQuestion: QuizData;
heading: string;
brandColor: string;
durationSeconds: number;
}> = ({ quizQuestion, heading, brandColor, durationSeconds }) => {
const { fps } = useVideoConfig();
// スライドの前半で考え、後半で正解を明かす
const revealFrame = Math.round((durationSeconds / 2) * fps);
return (
<AbsoluteFill
style={{
background: '#0f172a',
padding: '80px 120px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<Sequence from={0} name="QuizLabel" layout="none">
<QuizLabel brandColor={brandColor} />
</Sequence>
<Sequence from={8} name="Question" layout="none">
<QuizQuestion text={quizQuestion.question} />
</Sequence>
{quizQuestion.options.map((option, i) => (
<Sequence key={i} from={18 + i * 6} name={`Option-${i}`} layout="none">
<QuizOption
text={option}
index={i}
isCorrect={i === quizQuestion.correctIndex}
revealFrame={revealFrame}
brandColor={brandColor}
/>
</Sequence>
))}
<Sequence from={revealFrame} name="Explanation" layout="none">
<QuizExplanation text={quizQuestion.explanation} brandColor={brandColor} />
</Sequence>
</AbsoluteFill>
);
};
const QuizOption: React.FC<{
text: string;
index: number;
isCorrect: boolean;
revealFrame: number;
brandColor: string;
}> = ({ text, index, isCorrect, revealFrame, brandColor }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const enterProgress = spring({ frame, fps, config: { mass: 0.8, damping: 14, stiffness: 100 } });
const isRevealed = frame >= revealFrame;
const labels = ['A', 'B', 'C', 'D'];
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 16,
padding: '14px 20px',
borderRadius: 10,
border: `1.5px solid ${isRevealed && isCorrect ? brandColor : 'rgba(248, 250, 252, 0.12)'}`,
background: isRevealed && isCorrect ? `${brandColor}22` : 'transparent',
marginBottom: 12,
opacity: enterProgress,
transform: `translateX(${interpolate(enterProgress, [0, 1], [-16, 0])}px)`,
}}
>
<span
style={{
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
fontSize: 13,
fontWeight: 700,
color: isRevealed && isCorrect ? brandColor : 'rgba(248, 250, 252, 0.4)',
width: 24,
textAlign: 'center',
flexShrink: 0,
}}
>
{labels[index]}
</span>
<span
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 22,
color: isRevealed && isCorrect ? '#f8fafc' : 'rgba(248, 250, 252, 0.7)',
fontWeight: isRevealed && isCorrect ? 600 : 400,
}}
>
{text}
</span>
</div>
);
};
// QuizLabel, QuizQuestion, QuizExplanationは省略
// (`spring`を使ったopacity/translateアニメーションの標準実装)
受講証明書の自動生成
受講者の名前・コース名・修了日が入ったパーソナライズ済み証明書動画を自動生成できる。受講者データのCSVやデータベースから情報を読み込み、バッチレンダリングすることで全受講者の証明書を一括生成する。
// compositions/Certificate.tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from 'remotion';
export interface CertificateProps {
studentName: string;
courseName: string;
completionDate: string;
instructorName: string;
brandColor: string;
}
export const Certificate: React.FC<CertificateProps> = ({
studentName, courseName, completionDate, instructorName, brandColor,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const containerProgress = spring({
frame,
fps,
from: 0.92,
to: 1,
config: { mass: 1, stiffness: 80, damping: 18, overshootClamping: true },
});
const contentProgress = spring({
frame: Math.max(0, frame - 10),
fps,
config: { damping: 16, stiffness: 85 },
});
const formattedDate = new Date(completionDate).toLocaleDateString('ja-JP', {
year: 'numeric', month: 'long', day: 'numeric',
});
return (
<AbsoluteFill
style={{ background: '#f8fafc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<div
style={{
width: 820,
background: '#ffffff',
borderRadius: 16,
boxShadow: '0 32px 80px rgba(0,0,0,0.1)',
border: `3px solid ${brandColor}`,
padding: '64px 80px',
transform: `scale(${containerProgress})`,
opacity: interpolate(containerProgress, [0.92, 1], [0, 1]),
textAlign: 'center',
}}
>
<div
style={{
width: interpolate(contentProgress, [0, 1], [0, 120]),
height: 4,
background: brandColor,
borderRadius: 2,
margin: '0 auto 32px',
}}
/>
<p
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 13,
fontWeight: 600,
color: brandColor,
textTransform: 'uppercase',
letterSpacing: '0.12em',
margin: '0 0 16px',
opacity: contentProgress,
}}
>
修了証明書
</p>
<p
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 18,
color: '#64748b',
margin: '0 0 12px',
opacity: contentProgress,
}}
>
以下の方が所定のカリキュラムを修了したことを証明します
</p>
<h1
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 52,
fontWeight: 800,
color: '#0f172a',
margin: '0 0 12px',
opacity: contentProgress,
transform: `translateY(${interpolate(contentProgress, [0, 1], [20, 0])}px)`,
}}
>
{studentName}
</h1>
<h2
style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 28,
fontWeight: 700,
color: brandColor,
margin: '0 0 32px',
opacity: contentProgress,
}}
>
{courseName}
</h2>
<div style={{ width: '60%', height: 1, background: '#e2e8f0', margin: '0 auto 24px' }} />
<div
style={{
display: 'flex',
justifyContent: 'space-between',
opacity: contentProgress,
}}
>
<div style={{ textAlign: 'left' }}>
<p style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 12, color: '#94a3b8', margin: '0 0 4px', letterSpacing: '0.08em',
}}>
講師
</p>
<p style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 18, fontWeight: 600, color: '#0f172a', margin: 0,
}}>
{instructorName}
</p>
</div>
<div style={{ textAlign: 'right' }}>
<p style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 12, color: '#94a3b8', margin: '0 0 4px', letterSpacing: '0.08em',
}}>
修了日
</p>
<p style={{
fontFamily: '"Yu Gothic", "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
fontSize: 18, fontWeight: 600, color: '#0f172a', margin: 0,
}}>
{formattedDate}
</p>
</div>
</div>
</div>
</AbsoluteFill>
);
};
全受講者分の証明書バッチ生成
// scripts/renderCertificates.ts
import { renderMedia, selectComposition } from '@remotion/renderer';
import path from 'path';
const students = [
{ studentName: '山田 太郎', courseName: 'Reactの基礎', completionDate: '2026-06-05' },
{ studentName: '鈴木 花子', courseName: 'Reactの基礎', completionDate: '2026-06-05' },
];
const BUNDLE_URL = '/path/to/bundle';
const CONCURRENCY = 8;
async function renderCertificate(student: typeof students[0], i: number) {
const props = { ...student, instructorName: 'Jordan Lee', brandColor: '#6366f1' };
const composition = await selectComposition({
serveUrl: BUNDLE_URL, id: 'Certificate', inputProps: props,
});
await renderMedia({
composition,
serveUrl: BUNDLE_URL,
codec: 'h264',
outputLocation: path.join('output', 'certificates', `${student.studentName}.mp4`),
inputProps: props,
});
console.log(`[${i + 1}/${students.length}] ${student.studentName} 完了`);
}
async function run() {
for (let i = 0; i < students.length; i += CONCURRENCY) {
await Promise.all(
students.slice(i, i + CONCURRENCY).map((s, j) => renderCertificate(s, i + j))
);
}
}
run();
プラットフォーム別の出力設定
| プラットフォーム | 幅 | 高さ | fps | 最大ファイルサイズ | 備考 |
|---|---|---|---|---|---|
| Udemy | 1920 | 1080 | 30 | 制限なし | 最低720p必須 |
| Teachable | 1920 | 1080 | 30 | 4GB/本 | H.264 MP4 |
| YouTube(限定公開) | 1920 | 1080 | 30 | 256GB | 標準設定 |
| 社内LMS | 1920 | 1080 | 30 | 要確認 | プラットフォームによる |
Root.tsxのコンポジション登録では合計フレーム数を動的に計算する。
function calculateTotalFrames(lesson: Lesson, fps: number): number {
return Math.round(
lesson.slides.reduce((acc, s) => acc + s.durationSeconds, 0) * fps
);
}
<Composition
id="LessonVideo"
component={LessonVideo}
durationInFrames={calculateTotalFrames(sampleLesson, 30)}
fps={30}
width={1920}
height={1080}
defaultProps={sampleLesson}
/>
よくある質問
Q: スライドの尺はどのように計算すればよいですか?
レッスンの全スライドのdurationSecondsを合計してfpsをかけた値がdurationInFramesになります。slides.reduce((acc, s) => acc + s.durationSeconds, 0) * fpsというヘルパー関数で計算できます。ナレーション音声の長さと一致するように各スライドの尺を設定することで、音声とスライドが自然に同期します。
Q: ナレーションをTTS(テキスト読み上げ)で自動生成できますか?
技術的には可能です。各スライドのテキストをTTS APIに渡して音声ファイルを生成し、その音声ファイルの長さをスライドのdurationSecondsとして設定します。TTS APIはセグメントごとの音声長(秒)を返すものが多いため、その値をそのまま使えます。
Q: クイズスライドで実際に選択肢をクリックできるようにしたいです。
動画ファイルはインタラクションに対応していません。インタラクティブなクイズを実現したい場合は、@remotion/playerコンポーネントをReact Webアプリに埋め込み、<Player>コンポーネントの上にHTMLボタンを重ねる構成が適切です。Remotion側は視覚的なプレゼンテーションを担当し、インタラクションはReactの状態管理で処理します。
Q: 複数言語版のレッスン動画を効率よく作るには?
同じコンポジションに異なるlocaleプロップを渡し、スライドコンポーネント内でロケールに応じたテキストを参照する構成が効率的です。日本語版と英語版を同じバッチスクリプトでループレンダリングすれば、テンプレートの変更なしに複数言語に対応できます。
Q: 動画の途中でアニメーション付きの図解(フローチャートなど)を入れたいです。
Remotionでは<svg>要素のstrokeDasharrayとstrokeDashoffsetをinterpolate()でアニメーションさせることで、線が描かれていくような図解アニメーションが実現できます。各ノードと矢印を別々の<Sequence>に配置して時間差で登場させれば、ステップごとに概念が積み上がるフローチャートが作れます。
Q: 長時間のレッスン動画はレンダリングに時間がかかりますか?
レンダリング時間はスライド数ではなくフレーム数(= 動画の長さ × fps)に比例します。20分のレッスンは30fpsで36,000フレームです。@remotion/lambdaを使えばAWS Lambda上で並列レンダリングでき、長尺動画でも現実的な時間で処理できます。
RenderCompのeラーニングテンプレートで開発を加速する
本記事で解説したスライドコンポーネント・音声同期・クイズ演出・証明書生成のアーキテクチャは、RenderCompテンプレートライブラリのeラーニングテンプレートで実際に採用している構成だ。型定義済みのコンポジション・サンプルレッスンデータ・バッチレンダリングスクリプトがすべてセットになっている。
教育コンテンツの量産を最短ルートで始めたい方はrendercomp.comをチェックしてほしい。