T-CREATOR

Node.js × FFmpeg でサムネイル自動生成:キーフレーム抽出とスプライト化

Node.js × FFmpeg でサムネイル自動生成:キーフレーム抽出とスプライト化

現代の Web 動画サービスにおいて、動画の内容を一目で把握できるサムネイル画像は不可欠な要素です。YouTube や Netflix をはじめとする主要プラットフォームでは、動画の要点を捉えた魅力的なサムネイルが視聴者の関心を引き、クリック率向上に直結しています。

今回は、Node.js と FFmpeg を活用して、動画から自動的にキーフレームを抽出し、効率的なスプライト画像を生成するシステムの実装方法をご紹介します。この技術により、手動でのサムネイル作成作業から解放され、大量の動画コンテンツに対しても一貫性のある高品質なサムネイル生成が可能になります。

背景

FFmpeg と Node.js 連携の技術的価値

FFmpeg は動画・音声処理において業界標準とされる強力なコマンドラインツールです。数百種類のコーデックに対応し、動画の変換、切り出し、フィルタリングなど幅広い処理を高速で実行できます。

Node.js と FFmpeg を組み合わせることで、JavaScript の非同期処理能力と FFmpeg の動画処理能力を融合した、スケーラブルなサムネイル生成システムが構築できます。

以下の図は、Node.js と FFmpeg の基本的な連携フローを示しています。

mermaidflowchart LR
    video[動画ファイル] -->|アップロード| node[Node.js サーバー]
    node -->|コマンド実行| ffmpeg[FFmpeg エンジン]
    ffmpeg -->|フレーム抽出| frames[キーフレーム群]
    frames -->|画像合成| sprite[スプライト画像]
    sprite -->|保存| storage[(ストレージ)]
    node -->|レスポンス| client[クライアント]

このフローにより、動画アップロードから最終的なサムネイル配信まで、一連の処理を自動化できます。

キーフレーム抽出とスプライト画像の仕組み

キーフレームの概念

キーフレームとは、動画において完全な画像情報を持つフレームのことです。動画圧縮では、キーフレーム間の差分のみを保存することでファイルサイズを削減しており、キーフレームは動画の内容を代表する重要な場面を含んでいます。

スプライト画像の利点

スプライト画像は、複数の個別画像を一枚にまとめた合成画像です。Web ブラウザでは一度の HTTP リクエストで複数のサムネイルを取得でき、表示パフォーマンスが大幅に向上します。

mermaidsequenceDiagram
    participant Browser as ブラウザ
    participant Server as サーバー
    participant Storage as ストレージ

    note over Browser,Storage: 従来の個別画像方式
    Browser->>Server: サムネイル1 リクエスト
    Server->>Storage: 画像1 取得
    Storage-->>Server: 画像1 レスポンス
    Server-->>Browser: 画像1 レスポンス

    Browser->>Server: サムネイル2 リクエスト
    Server->>Storage: 画像2 取得
    Storage-->>Server: 画像2 レスポンス
    Server-->>Browser: 画像2 レスポンス

    note over Browser,Storage: スプライト画像方式
    Browser->>Server: スプライト画像リクエスト
    Server->>Storage: スプライト取得
    Storage-->>Server: 一枚の合成画像
    Server-->>Browser: 合成画像(複数サムネイル含有)

課題

従来のサムネイル生成手法の限界

手動作成の非効率性

多くの動画プラットフォームでは、コンテンツ制作者が手動でサムネイル画像を選択・編集しています。しかし、この手法には以下のような限界があります:

課題項目問題点影響
作業時間1 動画につき 10-30 分の編集時間大量動画への対応困難
品質のばらつき制作者のスキルに依存ブランド統一性の欠如
スケーラビリティ人的リソースに制約サービス拡大の阻害要因

単一画像方式のパフォーマンス課題

従来の個別サムネイル配信では、動画プレーヤーでのシークバープレビューなど、インタラクティブな機能実装時に HTTP リクエスト数が膨大になります。10 分動画で 10 秒間隔のサムネイルを表示する場合、60 枚の個別画像が必要となり、ページ読み込み時間が著しく増加します。

スプライト化による効率的な画像管理の必要性

ネットワーク負荷の軽減

スプライト画像を採用することで、以下のメリットが得られます:

  • リクエスト数の削減: 60 個の個別リクエストを 1 個に統合
  • キャッシュ効率の向上: 一度の取得で全サムネイルがブラウザキャッシュに保存
  • 帯域幅の最適化: HTTP ヘッダーのオーバーヘッドを大幅削減

管理コストの最小化

個別画像管理では、ファイル名の命名規則、ディレクトリ構造の維持、削除時の一貫性確保など、運用面での複雑性が問題となります。スプライト画像なら、動画 1 本につき 1 枚のファイル管理で済み、運用効率が向上します。

解決策

Node.js で FFmpeg を操作する設計思想

非同期処理によるスケーラビリティ

Node.js のイベントループを活用し、複数の動画処理を並行実行することで、サーバーリソースを効率的に活用できます。FFmpeg の処理時間中も他のリクエストをブロックせず、高いスループットを実現します。

以下は、基本的なアーキテクチャ設計を示しています。

mermaidflowchart TD
    upload[動画アップロード] --> queue[処理キュー]
    queue --> worker1[Worker Process 1]
    queue --> worker2[Worker Process 2]
    queue --> worker3[Worker Process 3]

    worker1 --> ffmpeg1[FFmpeg Instance]
    worker2 --> ffmpeg2[FFmpeg Instance]
    worker3 --> ffmpeg3[FFmpeg Instance]

    ffmpeg1 --> process1[キーフレーム抽出]
    ffmpeg2 --> process2[キーフレーム抽出]
    ffmpeg3 --> process3[キーフレーム抽出]

    process1 --> canvas[Canvas合成処理]
    process2 --> canvas
    process3 --> canvas

    canvas --> result[スプライト画像生成完了]

図で理解できる要点:

  • 複数のワーカープロセスによる並列処理体制
  • キューイングシステムによる負荷分散
  • 最終段階での画像合成プロセス

エラーハンドリングとリトライ機構

動画処理では破損ファイルや非対応フォーマットなど、予期しないエラーが発生する可能性があります。堅牢なシステムには、以下の仕組みが必要です:

  • 段階的エラー検出: FFmpeg の出力ログを解析し、エラー種別を特定
  • 自動リトライ: 一時的なリソース不足などに対する再試行機構
  • フォールバック処理: 抽出失敗時の代替サムネイル生成

キーフレーム抽出とスプライト化のアルゴリズム

インテリジェントなキーフレーム選択

単純な時間間隔ベースの抽出ではなく、画像の情報量を考慮したスマートな選択アルゴリズムを実装します:

  1. シーン変更検出: 連続フレーム間の差分値を算出
  2. 情報エントロピー評価: 画像の詳細度を数値化
  3. 顔認識フィルター: 人物が映っているフレームを優先
  4. 重複除去: 類似度の高いフレームを統合

効率的なスプライト配置戦略

生成されるスプライト画像のサイズとロード時間を最適化するため、以下の配置戦略を採用します:

  • 動的グリッド計算: 抽出フレーム数に応じた最適な縦横比
  • パディング最小化: 無駄な余白を削減
  • 圧縮品質調整: 用途に応じた JPEG 品質レベルの自動選択

具体例

環境構築

FFmpeg のインストール

システムに FFmpeg をインストールします。各プラットフォーム向けの手順は以下の通りです:

bash# macOS (Homebrew使用)
brew install ffmpeg

# Ubuntu/Debian
sudo apt update
sudo apt install ffmpeg

# CentOS/RHEL
sudo yum install epel-release
sudo yum install ffmpeg

インストール確認とバージョンチェックを行います:

bashffmpeg -version
ffprobe -version

Node.js プロジェクトのセットアップ

プロジェクトディレクトリを作成し、必要な依存関係をインストールします:

bashmkdir video-thumbnail-generator
cd video-thumbnail-generator
yarn init -y

必要なパッケージをインストールします:

bashyarn add fluent-ffmpeg canvas sharp fs-extra
yarn add -D @types/fluent-ffmpeg @types/sharp typescript ts-node

TypeScript 設定ファイルを作成します:

json{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

プロジェクト構造を整備します:

bashmkdir src
mkdir output
mkdir temp
touch src/index.ts
touch src/thumbnail-generator.ts
touch src/sprite-composer.ts

キーフレーム抽出の実装

基本的な FFmpeg 操作クラス

まず、FFmpeg を操作するための基本クラスを実装します:

typescriptimport ffmpeg from 'fluent-ffmpeg';
import { promises as fs } from 'fs';
import path from 'path';

interface VideoInfo {
  duration: number;
  width: number;
  height: number;
  fps: number;
}

class VideoProcessor {
  private videoPath: string;
  private outputDir: string;

  constructor(videoPath: string, outputDir: string) {
    this.videoPath = videoPath;
    this.outputDir = outputDir;
  }

動画の基本情報を取得するメソッドを実装します:

typescript  async getVideoInfo(): Promise<VideoInfo> {
    return new Promise((resolve, reject) => {
      ffmpeg.ffprobe(this.videoPath, (err, metadata) => {
        if (err) reject(err);

        const videoStream = metadata.streams.find(
          stream => stream.codec_type === 'video'
        );

        if (!videoStream) {
          reject(new Error('Video stream not found'));
          return;
        }

        resolve({
          duration: metadata.format.duration || 0,
          width: videoStream.width || 0,
          height: videoStream.height || 0,
          fps: eval(videoStream.r_frame_rate) || 30
        });
      });
    });
  }

インテリジェントなキーフレーム抽出

シーン変更を検出して最適なフレームを選択する機能を実装します:

typescript  async extractKeyframes(
    frameCount: number = 10
  ): Promise<string[]> {
    const videoInfo = await this.getVideoInfo();
    const interval = videoInfo.duration / frameCount;
    const outputFiles: string[] = [];

    // 一時ディレクトリを作成
    await fs.mkdir(this.outputDir, { recursive: true });

    return new Promise((resolve, reject) => {
      const command = ffmpeg(this.videoPath);

      // シーン変更検出フィルターを適用
      command
        .complexFilter([
          'select=gt(scene\\,0.3)',
          `scale=320:240`,
          'fps=1/' + interval.toFixed(2)
        ])
        .format('image2')
        .option('-vsync 0')
        .option('-y') // ファイル上書きを許可
        .on('end', () => {
          resolve(outputFiles);
        })
        .on('error', (err) => {
          reject(new Error(`Frame extraction failed: ${err.message}`));
        })
        .save(path.join(this.outputDir, 'frame_%03d.jpg'));
    });
  }

抽出フレームの品質評価

画像の品質を数値化し、最適なフレームを選択する機能を追加します:

typescript  private async evaluateFrameQuality(imagePath: string): Promise<number> {
    const sharp = require('sharp');

    try {
      const { data, info } = await sharp(imagePath)
        .raw()
        .toBuffer({ resolveWithObject: true });

      // エントロピー計算による情報量評価
      const histogram = new Array(256).fill(0);

      for (let i = 0; i < data.length; i++) {
        histogram[data[i]]++;
      }

      let entropy = 0;
      const totalPixels = info.width * info.height;

      histogram.forEach(count => {
        if (count > 0) {
          const probability = count / totalPixels;
          entropy -= probability * Math.log2(probability);
        }
      });

      return entropy;
    } catch (error) {
      console.error('品質評価エラー:', error);
      return 0;
    }
  }

スプライト画像生成の実装

Canvas API を使用した画像合成

抽出されたキーフレームを一枚のスプライト画像に合成するクラスを実装します:

typescriptimport { createCanvas, loadImage } from 'canvas';
import sharp from 'sharp';

interface SpriteConfig {
  frameWidth: number;
  frameHeight: number;
  columns: number;
  padding: number;
  quality: number;
}

class SpriteComposer {
  private config: SpriteConfig;

  constructor(config: Partial<SpriteConfig> = {}) {
    this.config = {
      frameWidth: 160,
      frameHeight: 90,
      columns: 5,
      padding: 2,
      quality: 80,
      ...config
    };
  }

最適なグリッドレイアウトを計算するメソッドを実装します:

typescript  private calculateOptimalGrid(frameCount: number): {
    columns: number;
    rows: number;
  } {
    // アスペクト比を考慮した最適なグリッドを計算
    const targetAspectRatio = 16 / 9;
    let bestDiff = Infinity;
    let bestGrid = { columns: this.config.columns, rows: 1 };

    for (let cols = 1; cols <= Math.ceil(Math.sqrt(frameCount)); cols++) {
      const rows = Math.ceil(frameCount / cols);
      const gridAspectRatio =
        (cols * this.config.frameWidth) / (rows * this.config.frameHeight);

      const diff = Math.abs(gridAspectRatio - targetAspectRatio);

      if (diff < bestDiff) {
        bestDiff = diff;
        bestGrid = { columns: cols, rows };
      }
    }

    return bestGrid;
  }

高品質な画像合成処理

複数のフレーム画像を一枚のスプライト画像に合成します:

typescript  async compositeSprite(
    imagePaths: string[],
    outputPath: string
  ): Promise<void> {
    const { columns, rows } = this.calculateOptimalGrid(imagePaths.length);

    const spriteWidth =
      columns * this.config.frameWidth + (columns - 1) * this.config.padding;
    const spriteHeight =
      rows * this.config.frameHeight + (rows - 1) * this.config.padding;

    // Canvasを初期化
    const canvas = createCanvas(spriteWidth, spriteHeight);
    const ctx = canvas.getContext('2d');

    // 背景を黒で塗りつぶし
    ctx.fillStyle = '#000000';
    ctx.fillRect(0, 0, spriteWidth, spriteHeight);

    // 各フレーム画像を配置
    for (let i = 0; i < imagePaths.length; i++) {
      const row = Math.floor(i / columns);
      const col = i % columns;

      const x = col * (this.config.frameWidth + this.config.padding);
      const y = row * (this.config.frameHeight + this.config.padding);

      try {
        const image = await loadImage(imagePaths[i]);
        ctx.drawImage(
          image,
          x, y,
          this.config.frameWidth,
          this.config.frameHeight
        );
      } catch (error) {
        console.error(`画像読み込みエラー: ${imagePaths[i]}`, error);
        // エラー時は黒い矩形で代替
        ctx.fillStyle = '#333333';
        ctx.fillRect(x, y, this.config.frameWidth, this.config.frameHeight);
      }
    }

    // 高品質JPEGとして保存
    const buffer = canvas.toBuffer('image/jpeg', {
      quality: this.config.quality / 100
    });

    await sharp(buffer)
      .jpeg({ quality: this.config.quality })
      .toFile(outputPath);
  }

メタデータ生成機能

フロントエンドでの座標計算を支援するメタデータを生成します:

typescript  generateSpriteMetadata(
    frameCount: number,
    videoDuration: number
  ): SpriteMetadata {
    const { columns, rows } = this.calculateOptimalGrid(frameCount);
    const frames: FrameInfo[] = [];

    for (let i = 0; i < frameCount; i++) {
      const row = Math.floor(i / columns);
      const col = i % columns;

      frames.push({
        index: i,
        timestamp: (videoDuration / frameCount) * i,
        x: col * (this.config.frameWidth + this.config.padding),
        y: row * (this.config.frameHeight + this.config.padding),
        width: this.config.frameWidth,
        height: this.config.frameHeight
      });
    }

    return {
      frameCount,
      columns,
      rows,
      frameWidth: this.config.frameWidth,
      frameHeight: this.config.frameHeight,
      totalWidth: columns * this.config.frameWidth +
                  (columns - 1) * this.config.padding,
      totalHeight: rows * this.config.frameHeight +
                   (rows - 1) * this.config.padding,
      frames
    };
  }

interface SpriteMetadata {
  frameCount: number;
  columns: number;
  rows: number;
  frameWidth: number;
  frameHeight: number;
  totalWidth: number;
  totalHeight: number;
  frames: FrameInfo[];
}

interface FrameInfo {
  index: number;
  timestamp: number;
  x: number;
  y: number;
  width: number;
  height: number;
}

完全なサムネイル生成システムの構築

メインアプリケーションクラス

これまでの機能を統合したメインアプリケーションを実装します:

typescriptimport path from 'path';
import { promises as fs } from 'fs';

class ThumbnailGenerator {
  private videoProcessor: VideoProcessor;
  private spriteComposer: SpriteComposer;
  private tempDir: string;

  constructor(videoPath: string, tempDir: string = './temp') {
    this.tempDir = tempDir;
    this.videoProcessor = new VideoProcessor(videoPath, tempDir);
    this.spriteComposer = new SpriteComposer();
  }

  async generateThumbnailSprite(
    outputPath: string,
    frameCount: number = 10
  ): Promise<SpriteMetadata> {
    try {
      // 一時ディレクトリを作成
      await fs.mkdir(this.tempDir, { recursive: true });

      // ビデオ情報を取得
      const videoInfo = await this.videoProcessor.getVideoInfo();
      console.log('動画情報:', videoInfo);

      // キーフレームを抽出
      console.log('キーフレーム抽出開始...');
      const framePaths = await this.videoProcessor.extractKeyframes(frameCount);
      console.log(`${framePaths.length}個のフレームを抽出しました`);

      // スプライト画像を生成
      console.log('スプライト画像合成開始...');
      await this.spriteComposer.compositeSprite(framePaths, outputPath);

      // メタデータを生成
      const metadata = this.spriteComposer.generateSpriteMetadata(
        framePaths.length,
        videoInfo.duration
      );

      // 一時ファイルをクリーンアップ
      await this.cleanup();

      console.log('サムネイルスプライト生成完了:', outputPath);
      return metadata;

    } catch (error) {
      await this.cleanup();
      throw new Error(`サムネイル生成エラー: ${error.message}`);
    }
  }

エラーハンドリングとクリーンアップ

堅牢な運用のためのエラー処理機能を実装します:

typescript  private async cleanup(): Promise<void> {
    try {
      const files = await fs.readdir(this.tempDir);
      const deletePromises = files.map(file =>
        fs.unlink(path.join(this.tempDir, file))
      );
      await Promise.all(deletePromises);
    } catch (error) {
      console.warn('一時ファイル削除エラー:', error);
    }
  }

  async generateWithRetry(
    outputPath: string,
    frameCount: number = 10,
    maxRetries: number = 3
  ): Promise<SpriteMetadata> {
    let lastError: Error;

    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        console.log(`生成試行 ${attempt}/${maxRetries}`);
        return await this.generateThumbnailSprite(outputPath, frameCount);
      } catch (error) {
        lastError = error;
        console.error(`試行 ${attempt} 失敗:`, error.message);

        if (attempt < maxRetries) {
          // 指数バックオフで待機
          const waitTime = Math.pow(2, attempt - 1) * 1000;
          console.log(`${waitTime}ms 待機後に再試行します...`);
          await new Promise(resolve => setTimeout(resolve, waitTime));
        }
      }
    }

    throw new Error(
      `${maxRetries}回の試行後も生成に失敗: ${lastError.message}`
    );
  }
}

使用例と API 実装

Express.js を使用した実用的な API エンドポイントを実装します:

typescriptimport express from 'express';
import multer from 'multer';
import path from 'path';

const app = express();
const upload = multer({ dest: 'uploads/' });

app.post(
  '/api/generate-thumbnail',
  upload.single('video'),
  async (req, res) => {
    try {
      if (!req.file) {
        return res.status(400).json({
          error: 'ビデオファイルが必要です',
        });
      }

      const frameCount =
        parseInt(req.body.frameCount) || 10;
      const videoPath = req.file.path;
      const outputPath = path.join(
        'output',
        `sprite_${Date.now()}.jpg`
      );

      const generator = new ThumbnailGenerator(videoPath);
      const metadata = await generator.generateWithRetry(
        outputPath,
        frameCount
      );

      res.json({
        success: true,
        spritePath: outputPath,
        metadata: metadata,
      });
    } catch (error) {
      console.error('API エラー:', error);
      res.status(500).json({
        error: 'サムネイル生成に失敗しました',
        details: error.message,
      });
    }
  }
);

app.listen(3000, () => {
  console.log(
    'サムネイル生成サーバーが起動しました (ポート: 3000)'
  );
});

まとめ

Node.js と FFmpeg を活用したサムネイル自動生成システムにより、動画コンテンツの魅力を効果的に伝える仕組みを構築できます。本記事でご紹介した実装では、以下の重要な機能を実現しました。

まず、インテリジェントなキーフレーム抽出により、単純な時間間隔ベースではない、動画の内容を適切に表現するフレーム選択が可能になりました。シーン変更検出と画像品質評価を組み合わせることで、視覚的に魅力的なサムネイルを自動生成できます。

次に、スプライト画像技術の導入により、Web アプリケーションでのパフォーマンスが大幅に向上しました。複数の HTTP リクエストを 1 回に統合し、キャッシュ効率を最大化することで、ユーザー体験の質が向上します。

また、堅牢なエラーハンドリングとリトライ機構により、本番環境での安定運用が可能です。予期しないエラーに対する自動復旧機能と適切なログ出力により、運用負荷を最小限に抑制できます。

実装したシステムは、小規模な Web サービスから大規模な動画プラットフォームまで、スケーラブルに対応できる設計となっています。Node.js の非同期処理能力と FFmpeg の高性能な動画処理を組み合わせることで、効率的なサムネイル生成パイプラインが完成しました。

今後の発展として、機械学習を活用した顔認識や物体検出の統合、クラウドストレージとの連携、リアルタイム処理対応などが考えられます。本記事の実装をベースに、さらなる機能拡張を進めていただければと思います。

関連リンク