Help us understand the problem. What is going on with this article?

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

はじめに

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

実現すること

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

コード

参考にしたもの

環境

firebase --version
6.4.0

node -v
v6.16.0

tsc -v
Version 3.3.3

npm install したもの

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

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

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-promise');

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秒地点のサムネイルを作成しています。

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 からそれぞれ削除します。

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から有効にしてください。

IAM.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://github.com/firebase/functions-samples/tree/Node-8/generate-thumbnail にも記載されています)

role.png

成功したとき

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

thumbnail.png
firestore.png

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

おわりに

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした