Node.jsで動画から再生ボタンを入れたサムネイル画像を作成する


ffmpegで動画からサムネイル画像を作成してみます。
これをfluent-ffmpegというnpmライブラリを使うことでNode.jsから操作できます。

今回は動画からサムネイル画像を作成します。

> ![CleanShot 2025-06-06 at 23.52.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/aa7224a1-3dd9-436b-bfa2-b2b880abc9ef.png)
>
> ↓↓↓↓ こういうどこかのフレームを切り出したpng画像を出力
> 
> ![CleanShot 2025-06-06 at 23.51.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/490800ab-f9e6-4027-aff9-41c6ca7ac479.png)


## ffmpegのインストールなど

Macを使ってますが事前にインストールしておきます。

```bash
$ brew install ffmpeg
```

インストール確認

```bash
$ which ffmpeg
/opt/homebrew/bin/ffmpeg
```

ちなみにNode.jsはv24です。

## 利用する動画

https://note.com/n0bisuke/n/nfdad7018d744

こちらで作った動画からサムネイルを取得します。

https://www.youtube.com/watch?v=13Lj2efS6iw

## 動画からフレームを切り出す

- ライブラリのインストール

```
$ npm i fluent-ffmpeg
```

- コード

```ts
import ffmpeg from 'fluent-ffmpeg';
import { basename, extname, dirname, join } from 'node:path';

async function createThumbnail(videoPath: string): Promise<string> {
  const videoName = basename(videoPath, extname(videoPath));
  const thumbnailPath = join(dirname(videoPath), `${videoName}_thumbnail.png`);
  
  console.log(`🎬 ${videoName} からサムネイル生成中...`);
  
  return new Promise((resolve, reject) => {
    ffmpeg(videoPath)
      .screenshots({
        timestamps: [1], //[1] => 1秒目 ['10%'] => 10%の長さの箇所 [1,5,10] => 1,5,10秒で複数画像  
        filename: basename(thumbnailPath),
        folder: dirname(thumbnailPath),
        size: '1280x720'
      })
      .on('end', () => {
        console.log(`✅ 完了: ${thumbnailPath}`);
        resolve(thumbnailPath);
      })
      .on('error', reject);
  });
}

// 実行
const videoPath = process.argv[2];
if (!videoPath) {
  console.log('使用方法: node simple-thumbnail.ts <動画パス>');
  process.exit(1);
}

createThumbnail(videoPath).catch(console.error);
```


.screenshotsのtimestampsの値を`[1]`にしてますが、これは1秒目のフレームを画像化する指定です。
`[1,5,10]`などにすると1秒目、5秒目、10秒目で3枚のスクリーンショットが抽出できます。

```ts
      .screenshots({
        timestamps: [1,5,10],
        filename: basename(thumbnailPath),
        folder: dirname(thumbnailPath),
        size: '1280x720'
      })
```

- 使ってみる

```bash
$ node simple-thumbnail.ts ./tmp/IMG_0588.MOV
```

> ![CleanShot 2025-06-06 at 23.56.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/48a9d82b-c88f-4cf4-9d3e-f148a32bc6e4.png)


こんな感じでpng画像が出力されます。

## 再生ボタンを入れた画像を作る

ちゃんと調べてないですが、drawtextのオプションでテキストを入れることができる模様です。

折角なので動画のサムネイルということで再生ボタンを入れた画像を作ってみます。

> ![CleanShot 2025-06-07 at 01.00.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/35387/96993303-76f1-4988-aa8b-e962180a004f.png)

```ts
import ffmpeg from 'fluent-ffmpeg';
import { basename, extname, dirname, join } from 'node:path';
import { unlink } from 'node:fs/promises';

async function createThumbnailWithPlayButton(videoPath: string): Promise<string> {
  const videoName = basename(videoPath, extname(videoPath));
  const baseThumbnailPath = join(dirname(videoPath), `${videoName}_base.png`);
  const thumbnailPath = join(dirname(videoPath), `${videoName}_thumbnail.png`);
  
  console.log(`🎬 ${videoName} から再生ボタン付きサムネイル生成中...`);
  
  return new Promise((resolve, reject) => {
    // 1. 基本サムネイル生成
    ffmpeg(videoPath)
      .screenshots({
        timestamps: [1], // 1秒目
        filename: basename(baseThumbnailPath),
        folder: dirname(baseThumbnailPath),
        size: '1280x720'
      })
      .on('end', () => {
        // 2. 再生ボタンを追加
        ffmpeg(baseThumbnailPath)
          .outputOptions([
            '-vf',
            'drawtext=text=▶️:fontsize=150:fontcolor=white:x=(w-tw)/2:y=(h-th)/2:borderw=5:bordercolor=black@0.9'
          ])
          .output(thumbnailPath)
          .on('end', async () => {
            console.log(`✅ 完了: ${thumbnailPath}`);
            await unlink(baseThumbnailPath); // 中間ファイル削除
            resolve(thumbnailPath);
          })
          .on('error', () => {
            // 再生ボタン追加失敗時は基本サムネイルを使用
            console.log(`✅ 完了（再生ボタンなし）: ${baseThumbnailPath}`);
            resolve(baseThumbnailPath);
          })
          .run();
      })
      .on('error', reject);
  });
}

// 実行
const videoPath = process.argv[2];
if (!videoPath) {
  console.log('使用方法: node simple_image.ts <動画パス>');
  process.exit(1);
}

createThumbnailWithPlayButton(videoPath).catch(console.error);
```

これはべんり

## 所感など

終わってみると割とサクッと画像切り出しができて便利ですね。

GPTのAPIなどに画像をあげつつ、盛り上がってる箇所を探してほしい選定などもやりたい