Firebase Storage に動画ファイルがアップロードされたら Cloud Functions で自動的にサムネイルを作成する

# はじめに

Firebase Storage に動画ファイルがアップロードされたら Cloud Functions で自動的にサムネイルを作成する方法について、備忘録として残します。

#### 実現すること

- Firebase Storage のアップロードトリガーで Functions を起動し、サムネイルを作成する
- 動画とサムネイルの署名つきURLを発行し、Firestore に保存する
- 動画が削除されたら Functions を起動し、サムネイルと Firestore の該当ドキュメントを削除する

#### コード

- [imamurh/functions-samples/generate-thumbnail-from-video](https://github.com/imamurh/functions-samples/tree/master/generate-thumbnail-from-video) (TypeScript)

#### 参考にしたもの

- https://github.com/firebase/functions-samples/tree/Node-8/generate-thumbnail
    - 画像がアップロードされたらサムネイルを作成する公式サンプル
- [Google Cloud Platform Japan 公式ブログ: ゲスト投稿 : ML API&#12289;Cloud Pub/Sub&#12289;Cloud Functions でサーバーレス のデジタル アーカイブを構築した Incentro](https://cloudplatform-jp.googleblog.com/2018/02/how-we-built-a-serverless-digital-archive-with-machine-learning-APIs-Cloud-Pub-Sub-and-Cloud-Functions.html)
    - ffmpeg でサムネイルを作成する部分を参考にしました

# 環境

```
firebase --version
8.6.0

node -v
v10.18.1

tsc -v
Version 3.9.7
```

# npm install したもの

```
npm install --save @google-cloud/storage mkdirp ffmpeg-static ffprobe-static fluent-ffmpeg @types/ffmpeg-static @types/ffprobe-static @types/fluent-ffmpeg @types/mkdirp
```

# ファイルがアップロードされたとき

```typescript:index.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
admin.initializeApp();
import * as storage from '@google-cloud/storage';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import * as crypto from 'crypto';
import mkdirp = require('mkdirp');

const TARGET_MIME_TYPE = 'video/mp4';
const STORAGE_TARGET_DIR = 'public/video';
const STORAGE_THUMBNAIL_DIR = 'public/video/thumbnail';
const DATABASE_DESTINATION = 'videos';

function validateObject(object: functions.storage.ObjectMetadata): boolean {
    const filePath = object.name || '';
    const fileDir = path.dirname(filePath);
    // Validate directory path.
    if (fileDir !== STORAGE_TARGET_DIR) {
        console.log(fileDir, ' is not a target directory.');
        return false;
    }
    // Validate MIME type.
    if (object.contentType !== TARGET_MIME_TYPE) {
        console.log('This is not a video.', object.contentType, object.name);
        return false;
    }
    return true
}

export const onVideoUpload = functions.region('asia-northeast1').storage.object().onFinalize(async (object) => {
    if (!validateObject(object)) {
        return
    }
    const filePath = object.name || '';
    const fileName = path.basename(filePath);
    const thumbnailPath = path.normalize(path.join(STORAGE_THUMBNAIL_DIR, fileName + '.jpg'));

    // Local temporary paths.
    const tmpPath = path.join(os.tmpdir(), filePath);
    const tmpDir = path.dirname(tmpPath);
    const tmpThumbnailPath = path.join(os.tmpdir(), thumbnailPath);
    const tmpThumbnailDir = path.dirname(tmpThumbnailPath);

    // Cloud Storage Bucket.
    const client = new storage.Storage();
    const bucket = client.bucket(object.bucket);

    // Hash for Document ID.
    const sha1 = crypto.createHash('sha1');
    sha1.update(filePath);
    const hash = sha1.digest('hex');

    // Create the temp directory where the storage file will be downloaded.
    await mkdirp(tmpDir);
    await mkdirp(tmpThumbnailDir);

    // Download file from bucket.
    await bucket.file(filePath).download({ destination: tmpPath });
    console.log('The file has been downloaded to', tmpPath);

    // Generate a thumbnail using ffmpeg.
    await generateThumbnail(tmpPath, tmpThumbnailPath);
    console.log('Thumbnail created at', tmpThumbnailPath);

    // Uploading the Thumbnail.
    await bucket.upload(tmpThumbnailPath, { destination: thumbnailPath, metadata: { contentType: 'image/jpeg' } });
    console.log('Thumbnail uploaded to Storage at', thumbnailPath);

    // Once the image has been uploaded delete the local files to free up disk space.
    fs.unlinkSync(tmpPath);
    fs.unlinkSync(tmpThumbnailPath);

    // Get the Signed URLs for the video and thumbnail.
    const results = await Promise.all([
        bucket.file(filePath).getSignedUrl({ action: 'read', expires: '03-01-2500' }),
        bucket.file(thumbnailPath).getSignedUrl({ action: 'read', expires: '03-01-2500' }),
    ]);
    const fileUrl = results[0][0];
    const thumbnailUrl = results[1][0];
    console.log('Got Signed URLs.');

    // Add the URLs to the Firestore.
    await admin.firestore().collection(DATABASE_DESTINATION).doc(hash).set({
        url: fileUrl,
        thumbnailUrl: thumbnailUrl,
        updated: new Date(),
    });
    console.log('The URLs saved to Firestore.');
});
```

画像からサムネイルを作成する公式サンプルとほぼ同じですが、いくつかポイントを。

- `storage.object().onFinalize()` は、動画がアップロードされたときだけでなく、ファイルがアップロード（上書き更新含む）されたら常に発火します。それは Functions の処理でサムネイル画像を生成してアップロードした場合も含みます。なので、アップロードされたファイルがサムネイルを生成すべき対象かどうかを判定する必要があります（下手を打つと Function が無限に発火し続けます）。このコードではアップロードされたファイルが所定のディレクトリ（public/video）であること、MIME type が `video/mp4` であることをチェックしています。
- Firestore に保存するドキュメントIDは自動生成するのではなく、事前に計算したハッシュ値を渡すようにしています。こうすることで上書きアップロードをしても同じドキュメントを更新することができ、また後述する削除時もドキュメントをIDで明示的に削除できます。

続いて ffmpeg でサムネイルを作成する部分のコードです。動画の1秒地点のサムネイルを作成しています。

```typescript:index.ts
// ffmpeg
import * as ffmpeg from 'fluent-ffmpeg';
import * as ffmpeg_static from 'ffmpeg-static';
import * as ffprobe_static from 'ffprobe-static';
ffmpeg.setFfmpegPath(ffmpeg_static.path);
ffmpeg.setFfprobePath(ffprobe_static.path);

function generateThumbnail(inputFile: string, outputFile: string) {
    const outputDir = path.dirname(outputFile);
    const outputFileName = path.basename(outputFile);

    return new Promise((resolve, reject) => {
        ffmpeg(inputFile)
            .on('end', function () {
                resolve();
            })
            .screenshots({
                timestamps: [1],
                filename: outputFileName,
                folder: outputDir
            });
    });
}
```

# ファイルが削除されたとき

アップロード時と同じサムネイルファイルパス、ハッシュ値を使って Storage と Firestore からそれぞれ削除します。

```typescript:index.ts
export const onVideoDelete = functions.region('asia-northeast1').storage.object().onDelete(async (object) => {
    if (!validateObject(object)) {
        return
    }
    const filePath = object.name || '';
    const fileName = path.basename(filePath);
    const thumbnailPath = path.normalize(path.join(STORAGE_THUMBNAIL_DIR, fileName + '.jpg'));

    // Cloud Storage Bucket.
    const client = new storage.Storage();
    const bucket = client.bucket(object.bucket);

    // Hash for Document ID.
    const sha1 = crypto.createHash('sha1');
    sha1.update(filePath);
    const hash = sha1.digest('hex');

    // Deleting the Thumbnail.
    await bucket.file(thumbnailPath).delete();
    console.log('Thumbnail deleted from Storage at', thumbnailPath);

    // Deleting the Document from Firestore.
    await admin.firestore().collection(DATABASE_DESTINATION).doc(hash).delete();
    console.log('Document deleted from Firestore.', hash);
});
```

# 動作確認

- Functions をデプロイします。
- Firebase コンソールの Storage を開き、public/video ディレクトリに mp4 の動画ファイルをアップロードします。
- 正常にサムネイル生成処理が完了したか、Functions のログを確認します。


## Functions でエラーが発生しているとき

#### Error: 9 FAILED_PRECONDITION: The Cloud Firestore API is not enabled for the project XXXXXXXX

Firestore が有効になっていないので、Firebase コンソールの Database から有効にしてください。

#### SigningError: A Forbidden error was returned while attempting to retrieve an access token for the Compute Engine built-in service account. This may be because the Compute Engine instance does not have the correct permission scopes specified. Identity and Access Management (IAM) API has not been used in project xxxxxxxxxxxx before or it is disabled. Enable it by visiting `https://console.developers.google.com/apis/api/iam.googleapis.com/overview?project=xxxxxxxxxxxx` then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.

署名つきURLの発行に必要な IAM API が有効になっていないため、エラーメッセージのURLから有効にしてください。

<img width="1210" alt="IAM.png" src="https://qiita-image-store.s3.amazonaws.com/0/121719/f70237f5-5042-9c9a-7f02-5ff55f179ba6.png">

#### SigningError: A Forbidden error was returned while attempting to retrieve an access token for the Compute Engine built-in service account. This may be because the Compute Engine instance does not have the correct permission scopes specified. Permission iam.serviceAccounts.signBlob is required to perform this operation on service account `projects/XXXXXXXX/serviceAccounts/XXXXXXXX@appspot.gserviceaccount.com`.

App Engine default service account に対して、Service Account Token Creator ロールを付与する必要があります。[Cloud Console > IAM & admin > IAM](https://console.cloud.google.com/iam-admin/iam?project=_) から該当アカウントに権限を付与します。（参考にした https://github.com/firebase/functions-samples/tree/Node-8/generate-thumbnail にも記載されています）

<img width="1209" alt="role.png" src="https://qiita-image-store.s3.amazonaws.com/0/121719/04d52843-8513-0c70-9dfe-2f06efb0369b.png">

## 成功したとき

サムネイルが Storage の public/video/thumbnail ディレクトリにアップロードされ、Firestore に署名つきURLを持つドキュメントが追加されます。

<img width="850" alt="thumbnail.png" src="https://qiita-image-store.s3.amazonaws.com/0/121719/b6c35e21-0c57-fbfe-a61d-a01db3f86a83.png">
<img width="963" alt="firestore.png" src="https://qiita-image-store.s3.amazonaws.com/0/121719/a9e06711-1579-19a2-a3c8-f55ef444da5a.png">

その後、元の動画ファイルを削除すると、サムネイルとFirestoreの該当ドキュメントも削除されます。

# おわりに

TypeScript 初めて書いてみましたがいいですね。

# 2020/03/20 追記

mkdirp-promise が応答せずタイムアウトしていたので mkdirp を使用するようサンプルコードと記事を修正しました。

- https://github.com/imamurh/functions-samples/commit/c7d42a1eb693c1f7949d773ea019120fa8e35d2b
- 参考: https://github.com/firebase/functions-samples/pull/687