🎬
R カテゴリ(Remotion テンプレート) — このページはブラウザで動作するインタラクティブ UI ではなく、mp4 動画を生成する Remotion プロジェクトのテンプレートを提供します。コードをコピーして Remotion Studio でプレビュー・レンダリングしてください。 — This page provides a Remotion project template that generates an mp4 video, not an interactive browser UI. Copy the code, preview in Remotion Studio, and render to mp4.

ライブデモ Live Demo

CSS アニメーションで Remotion 動画の内容をシミュレートしています。

CSS animation simulating the Remotion video output.

StatCard.tsx

AI向け説明 AI Description

`R-005` はコードが1文字ずつタイプされながら現れるコードエディター風シーンを生成する Remotion テンプレートです。 1920×1080 / 30fps / 12秒(360フレーム)で、次のシーン進行を自動で再現します: ①エディターウィンドウが spring でスライドインしてフェードイン(f0〜30)→ ②コードが1文字ずつタイピングアニメで出現しシンタックスハイライトが付く(f30〜330)→ ③タイピング完了後にカーソルが 0.5秒間隔でブリンク、紫のグローが出現(f330〜360)。 シンタックスハイライトは keyword(赤)・string(水色)・comment(グレー)・type(オレンジ)・jsx(緑)の5トークンタイプに対応。 差し替えポイントは `filename`・`code`(コード文字列)・`theme`(配色)の3箇所です。

`R-005` is a Remotion template that renders a code-editor scene where code appears character by character with syntax highlighting and a blinking cursor. 1920×1080 / 30fps / 12s (360 frames), with this automatic scene progression: ① Editor window spring-slides in and fades in (f0–30) → ② Code types character by character with syntax highlighting applied in real time (f30–330) → ③ After typing, cursor blinks at 0.5s intervals and a purple glow appears (f330–360). Syntax highlighting supports 5 token types: keyword (red), string (light blue), comment (gray), type (orange), jsx (green). The three swap points are: `filename`, `code` (the code string), and `theme` (colors).

調整可能パラメータ Adjustable Parameters

実装 Implementation

以下のファイルを新規プロジェクトに配置してください。

Place the following files in a new project.

src/index.ts
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';

registerRoot(RemotionRoot);
src/Root.tsx
import React from 'react';
import { Composition } from 'remotion';
import { TypingCodeReveal, typingCodeRevealSchema } from './compositions/TypingCodeReveal';

const defaultTheme = {
  bg:       '#0d1117',
  editorBg: '#161b22',
  tabBg:    '#1c2128',
  keyword:  '#ff7b72',
  string:   '#a5d6ff',
  comment:  '#8b949e',
  type:     '#ffa657',
  jsx:      '#7ee787',
  plain:    '#e6edf3',
  lineNum:  '#30363d',
  cursor:   '#f78166',
};

export const RemotionRoot: React.FC = () => (
  <>
    {/* ── Landscape 16:9 ──────────────────────────────── */}
    <Composition
      id="TypingCodeReveal"
      component={TypingCodeReveal}
      schema={typingCodeRevealSchema}
      durationInFrames={360}
      fps={30}
      width={1920}
      height={1080}
      defaultProps={{ theme: defaultTheme }}
    />

    {/* ── Vertical 9:16 (Stories / Reels) ─────────────── */}
    <Composition
      id="TypingCodeRevealVertical"
      component={TypingCodeReveal}
      schema={typingCodeRevealSchema}
      durationInFrames={360}
      fps={30}
      width={1080}
      height={1920}
      defaultProps={{ theme: defaultTheme }}
    />
  </>
);
src/compositions/TypingCodeReveal.tsx
import React from 'react';
import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import { z } from 'zod';
import '../styles.css';

// ─── Default code ────────────────────────────────────────────────────────────
const DEFAULT_CODE = `import React, { useState } from 'react';

interface CardProps {
  title: string;
  count: number;
}

export const StatCard: React.FC<CardProps> = ({
  title,
  count,
}) => {
  const [active, setActive] = useState(false);
  const cls = active ? 'card card--on' : 'card';

  return (
    <div className={cls} onClick={() => setActive(!active)}>
      <h2>{title}</h2>
      <span className="count">{count}</span>
    </div>
  );
};`;

// ─── Schema ──────────────────────────────────────────────────────────────────
const themeSchema = z.object({
  bg:       z.string().default('#0d1117'),
  editorBg: z.string().default('#161b22'),
  tabBg:    z.string().default('#1c2128'),
  keyword:  z.string().default('#ff7b72'),
  string:   z.string().default('#a5d6ff'),
  comment:  z.string().default('#8b949e'),
  type:     z.string().default('#ffa657'),
  jsx:      z.string().default('#7ee787'),
  plain:    z.string().default('#e6edf3'),
  lineNum:  z.string().default('#30363d'),
  cursor:   z.string().default('#f78166'),
});

export const typingCodeRevealSchema = z.object({
  filename:    z.string().default('StatCard.tsx'),
  code:        z.string().default(DEFAULT_CODE),
  typingStart: z.number().int().default(30),
  typingEnd:   z.number().int().default(330),
  theme:       themeSchema.default({}),
});
type Props = z.infer<typeof typingCodeRevealSchema>;

// ─── Tokenizer ───────────────────────────────────────────────────────────────
type TType = 'keyword' | 'string' | 'comment' | 'type' | 'jsx' | 'plain';
interface Token { type: TType; text: string }

const KEYWORDS = new Set([
  'import', 'export', 'const', 'let', 'var', 'from',
  'interface', 'return', 'type', 'default', 'function',
  'async', 'await', 'if', 'else', 'true', 'false',
  'null', 'undefined', 'React', 'FC', 'useState',
]);

function tokenize(code: string): Token[] {
  const out: Token[] = [];
  let i = 0;
  while (i < code.length) {
    // Line comment
    if (code[i] === '/' && code[i + 1] === '/') {
      let j = i + 2;
      while (j < code.length && code[j] !== '\n') j++;
      out.push({ type: 'comment', text: code.slice(i, j) });
      i = j; continue;
    }
    // String (single / double quote)
    if (code[i] === "'" || code[i] === '"' || code[i] === '`') {
      const q = code[i];
      let j = i + 1;
      while (j < code.length && code[j] !== q && code[j] !== '\n') {
        if (code[j] === '\\') j++;
        j++;
      }
      if (j < code.length && code[j] === q) j++;
      out.push({ type: 'string', text: code.slice(i, j) });
      i = j; continue;
    }
    // JSX tag
    if (code[i] === '<' && i + 1 < code.length && /[a-zA-Z/]/.test(code[i + 1])) {
      let j = i + 1;
      while (j < code.length && code[j] !== '>' && code[j] !== '\n') j++;
      if (j < code.length && code[j] === '>') j++;
      out.push({ type: 'jsx', text: code.slice(i, j) });
      i = j; continue;
    }
    // Identifier / keyword / type
    if (/[a-zA-Z_$]/.test(code[i])) {
      let j = i + 1;
      while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
      const word = code.slice(i, j);
      out.push({
        type: KEYWORDS.has(word) ? 'keyword' : /^[A-Z]/.test(word) ? 'type' : 'plain',
        text: word,
      });
      i = j; continue;
    }
    out.push({ type: 'plain', text: code[i] });
    i++;
  }
  return out;
}

function revealTokens(tokens: Token[], n: number): Token[] {
  let rem = n;
  const out: Token[] = [];
  for (const tok of tokens) {
    if (rem <= 0) break;
    if (rem >= tok.text.length) { out.push(tok); rem -= tok.text.length; }
    else { out.push({ type: tok.type, text: tok.text.slice(0, rem) }); rem = 0; }
  }
  return out;
}

// ─── Component ───────────────────────────────────────────────────────────────
export const TypingCodeReveal: React.FC<Props> = (props) => {
  const {
    filename    = 'StatCard.tsx',
    code        = DEFAULT_CODE,
    typingStart = 30,
    typingEnd   = 330,
    theme,
  } = props;

  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // Editor entrance spring
  const entranceSpring = spring({ frame, fps, config: { damping: 20, stiffness: 80 } });
  const editorY        = interpolate(entranceSpring, [0, 1], [80, 0]);
  const editorOpacity  = interpolate(entranceSpring, [0, 1], [0, 1]);

  // Typing progress
  const progress    = interpolate(frame, [typingStart, typingEnd], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const charsToShow = Math.floor(progress * code.length);

  // Cursor blink after typing
  const typingDone    = frame >= typingEnd;
  const cursorVisible = typingDone
    ? Math.floor(frame / (fps / 2)) % 2 === 0
    : true;

  // Tokenize and reveal
  const allTokens     = tokenize(code);
  const visibleTokens = revealTokens(allTokens, charsToShow);
  const visibleText   = visibleTokens.map(t => t.text).join('');
  const lines         = visibleText.split('\n');

  const C: Record<TType, string> = {
    keyword: theme?.keyword ?? '#ff7b72',
    string:  theme?.string  ?? '#a5d6ff',
    comment: theme?.comment ?? '#8b949e',
    type:    theme?.type    ?? '#ffa657',
    jsx:     theme?.jsx     ?? '#7ee787',
    plain:   theme?.plain   ?? '#e6edf3',
  };

  const renderedLines = lines.map((lineText, li) => {
    const lineToks = tokenize(lineText);
    const isLast   = li === lines.length - 1;
    return (
      <div key={li} style={{ display: 'flex', minHeight: 34, lineHeight: '34px' }}>
        <span style={{
          width: 64, textAlign: 'right', paddingRight: 20,
          color: theme?.lineNum ?? '#30363d',
          flexShrink: 0, userSelect: 'none',
        }}>
          {li + 1}
        </span>
        <span style={{ flex: 1, whiteSpace: 'pre' }}>
          {lineToks.map((t, ti) => (
            <span key={ti} style={{ color: C[t.type] }}>{t.text}</span>
          ))}
          {isLast && cursorVisible && (
            <span style={{
              display: 'inline-block', width: 3, height: 26,
              background: theme?.cursor ?? '#f78166',
              marginLeft: 1, verticalAlign: 'text-bottom',
            }} />
          )}
        </span>
      </div>
    );
  });

  // Post-typing glow
  const glowOpacity = interpolate(frame, [typingEnd + 10, typingEnd + 30], [0, 0.5], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  return (
    <AbsoluteFill style={{
      background: theme?.bg ?? '#0d1117',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }}>
      {/* Editor window */}
      <div style={{
        transform: `translateY(${editorY}px)`,
        opacity: editorOpacity,
        width: 1200,
        borderRadius: 14, overflow: 'hidden',
        boxShadow: '0 40px 100px rgba(0,0,0,0.7)',
        border: '1px solid #30363d',
      }}>
        {/* Title bar */}
        <div style={{
          background: theme?.tabBg ?? '#1c2128',
          padding: '14px 20px',
          display: 'flex', alignItems: 'center', gap: 10,
          borderBottom: `1px solid ${theme?.lineNum ?? '#30363d'}`,
        }}>
          {(['#ff5f57', '#febc2e', '#28c840'] as const).map((c, i) => (
            <div key={i} style={{ width: 14, height: 14, borderRadius: '50%', background: c }} />
          ))}
          <div style={{
            marginLeft: 16, background: theme?.editorBg ?? '#161b22',
            borderRadius: 6, padding: '5px 18px',
            fontSize: 22, color: theme?.plain ?? '#e6edf3',
            fontFamily: '"Fira Code", Consolas, monospace',
          }}>
            {filename}
          </div>
        </div>
        {/* Code body */}
        <div style={{
          background: theme?.editorBg ?? '#161b22',
          padding: '20px 20px 20px 0',
          fontSize: 22,
          fontFamily: '"Fira Code", Consolas, monospace',
          minHeight: 500,
        }}>
          {renderedLines}
        </div>
      </div>

      {/* Post-typing glow */}
      {glowOpacity > 0 && (
        <div style={{
          position: 'absolute', inset: 0, pointerEvents: 'none',
          background: 'radial-gradient(ellipse at center, rgba(127,86,217,0.2) 0%, transparent 70%)',
          opacity: glowOpacity,
        }} />
      )}
    </AbsoluteFill>
  );
};
src/styles.css
/* Global reset for Remotion canvas */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

/*
 * Font strategy
 * ──────────────────────────────────────────────────────────────────────────
 * Default: system-ui — no network dependency, safe for Lambda / CI rendering.
 *
 * To use a monospace font in Remotion Studio, uncomment the @import below.
 * For Lambda / serverless rendering, use @remotion/google-fonts or place
 * the font file in public/ and reference it via staticFile().
 *
 * @import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;600&display=swap');
 */
remotion.config.ts
import { Config } from '@remotion/cli/config';

Config.setVideoImageFormat('jpeg');
Config.setOverwriteOutput(true);
package.json (scripts & dependencies to add)
{
  "scripts": {
    "remotion:preview": "remotion studio",
    "remotion:render":  "remotion render TypingCodeReveal out/TypingCodeReveal.mp4 --codec h264"
  },
  "dependencies": {
    "remotion":      "^4.0.0",
    "@remotion/cli": "^4.0.0",
    "react":         "^18.0.0",
    "react-dom":     "^18.0.0",
    "zod":           "^3.22.0"
  },
  "devDependencies": {
    "@types/react":     "^18.0.0",
    "@types/react-dom": "^18.0.0",
    "typescript":       "^5.0.0"
  }
}

コマンド Commands

① 新規プロジェクト作成(推奨) ① Scaffold a new project (recommended)
# Remotion 公式スキャフォルドから始める
npx create-video@latest
# プロンプトで "Hello World" か "Blank" を選択し、上記ファイルを上書きする
② 既存 React プロジェクトに追加 ② Add to an existing React project
npm install remotion @remotion/cli zod
③ プレビュー(Remotion Studio) ③ Preview in Remotion Studio
npx remotion studio
# ブラウザで http://localhost:3000 が開きます
# フレームをスクラブしてタイピングアニメーションを確認できます
④ mp4 レンダリング → DevSnips に配置 ④ Render to mp4 → place in DevSnips
# ── Step 1: Remotion プロジェクト内でレンダー
npx remotion render TypingCodeReveal out/R-005.mp4 --codec h264
# → out/R-005.mp4 (1920×1080 / 30fps / H.264)

# ── Step 2: DevSnips の assets/ に配置
#    (DevSnips リポジトリのルートで実行)
mkdir -p src/assets/remotion
cp /path/to/remotion-project/out/R-005.mp4 src/assets/remotion/R-005.mp4

# ── Step 3: poster 画像を生成(ffmpeg がある場合)
ffmpeg -i src/assets/remotion/R-005.mp4 \
       -ss 00:00:08.000 -frames:v 1 \
       src/assets/remotion/R-005.png

# ── Step 4: DevSnips をビルド & デプロイ
npm run build
# → dist/assets/remotion/R-005.mp4 として
#   https://devsnips.dev/assets/remotion/R-005.mp4 で配信されます
⑤ 縦動画 (9:16) をレンダリング ⑤ Render the vertical (9:16) version
# Root.tsx に TypingCodeRevealVertical が定義済み (1080×1920)
npx remotion render TypingCodeRevealVertical out/R-005-vertical.mp4 --codec h264

# poster 生成
ffmpeg -i out/R-005-vertical.mp4 \
       -ss 00:00:08.000 -frames:v 1 \
       out/R-005-vertical.png

注意とバリエーション Notes & Variations