Cloud Functionsで動画のサムネイル画像を生成しよう

# 動画のサムネイルを作ろう
Firebase Storageに動画をアップロードしたらCloud Functionsで動画のサムネイル画像を生成したかったので色々と格闘しました。
その記録を残します。
以下の方は参考になると思います。

- Firebaseを使用している
- ファイルをFirebase Storageに保存している
- 動画のサムネイル画像を生成したい
- サムネイル画像をCloud Functionsで行いたい
- Cloud Functionsの使用言語はNode.js
- FFmpegというライブラリを活用したい

# 今回やったこと
ファイルがStorageにアップロードされたらCloud Functionsのイベントドリブン関数（onObjectFinalized）が発火され動画サムネイル生成用の関数が実行される。

# FFMpegを使用する上での比較
まず、FFMpegをいろんな方法で使用してみましたので、その比較を貼ります。
もともと画像のサムネイルを生成するファンクションが存在していて、Storageにファイルがアップロードされると、Cloud Functionsのサムネイル生成用ファンクションが実行され、環境内にあるtmpディレクトリにファイルをダウンロードして、そこからサムネイルを生成するという処理がありました。
動画もその方法で最初は取り組んでいたのですが、一つ問題が発生しました。
動画ファイルは割と容量が大きいため、tmpディレクトリに保存する過程で時間がかかってしまうということです。
実際に同じ事例を調べましたが、tmpディレクトリに保存する方法でやっているところがほとんどだったのでご紹介したいなと記事を書いている次第です。
調査・試行した結果が以下の比較表になります。

|手法|評価|成功率|パフォーマンス|コスト|セキュリティ| 備考 |
| ---- | ---- | ---- | ---- | ---- | ---- | ---- |
|全部ダウンロード|○|○|×|×|○|どんな形式でも対応可能。大容量ファイルでの課題あり|
一部ダウンロード|×|△|△|○|○|MOVファイルで失敗。MP4では効果的|
ストリーミング|×|△|○|○|○|MOVファイルで失敗。軽量ファイルに最適
署名付きURLの発行|◎|○|○|○|△|セキュリティリスクがあるが、効率は非常に良い|

## 全部ダウンロード
Storageにアップロードした動画をtmpディレクトリに全てダウンロードし、ローカルで処理を行う方法です。
確実性は高いものの、大容量ファイルではパフォーマンスに課題がありました。
2GBでは1:30程度もかかっています。

### メリット

- 動画全体をダウンロードするため、MOVやMP4を含む全てのファイル形式に対応可能
- ファイル構造に依存せず、失敗するケースが少ない

### デメリット

- 動画の容量が大きい場合、ダウンロードに時間がかかるためパフォーマンスが低下
- tmpディレクトリのストレージ消費が大きくなる
- ダウンロード時間が長引くと、ファンクションのタイムアウトリスクが高まる（2024/12/5現在のイベントドリブン関数の場合は9分などあまり気にしなくても良いかもしれない）
- 実行時間が長くなることで、課金コストが増加

※ [Cloud Run関数の料金](https://cloud.google.com/functions/pricing?hl=ja)

## 一部ダウンロード

動画ファイルの最初の一部分（例：5MB）をダウンロードして処理する方法です。
MOVファイルの場合、moovボックス（メタデータ）がファイル末尾に配置されることが多く、解析に失敗しました。

もしかしたらメタデータを抜き出せば成功する可能性はありますがファイルの形式による条件分岐などが必要になったり今後新しい形式のファイルに対応できない可能性があります。

qt-faststartを利用して、動画ファイルのメタデータ（moovボックス）をファイルの先頭に移動するツールを使えば解析できる可能性はあるようなのですが、今回は試していません。

### メリット

- 動画の一部のみをダウンロードするため、MP4ファイルではパフォーマンスが向上
- 容量を制限することで、ネットワーク使用量を削減

### デメリット

- MOVファイルなど、一部形式ではメタデータ不足により失敗
- 先頭部分に十分なデータがない場合、解析が不可能
- ファイル形式ごとに個別の対策が必要

## ストリーミング

動画データをストリーミングで直接処理する方法です。
MOVファイルのメタデータ位置が末尾にある場合、FFmpegが正しく動作しないため失敗しました。

### メリット

- ファイル全体をローカルに保存せずに処理可能
- ダウンロード時間が不要であるため、軽量な動画の場合は高速

### デメリット

- メタデータ不足などによりMOVファイルや特殊な形式に対応できない場合がある
- ストリーム処理におけるネットワークエラーや不安定さに依存

## 署名付きURLの発行
実際に採用した方法です。
パフォーマンスやコストでメリットがあるため、こちらにしました。
懸念点もあるのですが、許容できる範囲かと思っています。

### メリット

- 動画ファイルを完全にダウンロードせずにアクセス可能
- ファイル容量に依存せず、高パフォーマンスを実現
- ネットワーク転送量を大幅に削減可能

### デメリット

- URLが漏洩した場合、ファイルが第三者にアクセスされるリスク
- 有効期限を短く（例：5分）設定することでリスクを軽減
- URLの発行と使用をファンクション内に限定する必要がある


# 参考コード
そのままでも使用できるようにできる限りのコードを貼っていますが、ファイル名やパスの生成部分は省略していますのでそこはアプリケーションの仕様に合わせて実装してください。

```
const { Storage } = require('@google-cloud/storage')
const spawn = require('child-process-promise').spawn
const { onObjectFinalized } = require("firebase-functions/v2/storage")
const path = require('path')
const fs = require('fs')
const { error } = require("firebase-functions/logger")
const admin = require("firebase-admin")
const bucket = admin.storage().bucket(process.env.BUCKETNAME)

// 有効期限を秒単位で指定
const SIGNED_URL_EXPIRATION_SECONDS = 300

module.exports = onObjectFinalized({
    memory: "4GiB",
    timeoutSeconds: 540,
    region: process.env.PROJECT_REGION
}, async (event) => {
    const contentType = event.data.contentType
    if (!contentType.startsWith('video/')) return
    
    const filePath = event.data.name
    const thumbnailFileName = '{fileName}_thumbnail.jpg'
    const tmpThumbnailFilePath = path.join(os.tmpdir(), thumbnailFileName)
    const thumbnailFilePath = path.join(path.dirname(filePath), thumbnailFileName)
    
    try {
        const [signedUrl] = await bucket.file(filePath).getSignedUrl({
            version: 'v4',
            action: 'read',
            expires: Date.now() + SIGNED_URL_EXPIRATION_SECONDS * 1000, // セキュリティリスクを鑑みて短い時間を設定
        })

        await spawn(
            'ffmpeg', [
                '-i', signedUrl,       // 入力動画
                '-ss', '00:00:00.000', // 開始位置 (1秒地点だと1秒未満の動画の時にエラーになる)
                '-vframes', '1',       // 1フレームのみ取得
                tmpThumbnailFilePath,  // 出力先
            ]
        )
    
        await spawn(
            'convert', 
            [tmpThumbnailFilePath, 
            '-auto-orient',
            '-thumbnail', 
            sizeOption, 
            tmpThumbnailFilePath]
        )
    
        // サムネイル画像をアップロード
        await bucket.upload(tmpThumbnailFilePath, {
            destination: thumbnailFilePath,
            metadata: { 
                contentType: 'image/jpeg' 
            }
        })
    } catch (error) {
        console.error(error)
    } finally {
        fs.unlinkSync(tmpThumbnailFilePath)
    }
}
```

# 最後に
署名付きURLを使用したサムネイルの生成によって、劇的なパフォーマンス向上やコストの削減に成功しました。
2GBの動画では1分30秒もかかっていたサムネイル生成が1秒未満で生成できます。
一方でセキュリティリスクを懸念する方もいらっしゃると思います。
署名付きURLが漏洩してしまうと誰でもアクセスができてしまうからです。
ただ、有効期限を300秒（5分）という短い時間に設定していること、署名付きURLの取り扱いをクラウドファンクション内に限定することでリスクを最小限に抑えているかと思います。
この辺はご自身の判断で活用してみてください。

見ていただきありがとうございました。
他にもっと良い方法があればぜひ教えてください。