Reputation:
The code I currently have:
exports.generateThumbnail = functions.storage.object().onChange(event => {
...
.then(() => {
console.log('File downloaded locally to', tempFilePath);
// Generate a thumbnail using ImageMagick.
if (contentType.startsWith('video/')) {
return spawn('convert', [tempFilePath + '[0]', '-quiet', `${tempFilePath}.jpg`]);
} else if (contentType.startsWith('image/')){
return spawn('convert', [tempFilePath, '-thumbnail', '200x200', tempFilePath]);
The error I get in the console:
Failed AGAIN! { Error: spawn ffmpeg ENOENT
at exports._errnoException (util.js:1026:11)
at Process.ChildProcess._handle.onexit (internal/child_process.js:193:32)
at onErrorNT (internal/child_process.js:359:16)
at _combinedTickCallback (internal/process/next_tick.js:74:11)
at process._tickDomainCallback (internal/process/next_tick.js:122:9)
code: 'ENOENT',
errno: 'ENOENT',
syscall: 'spawn ffmpeg',
path: 'ffmpeg',
spawnargs: [ '-t', '1', '-i', '/tmp/myVideo.m4v', 'theThumbs.jpg' ] }
I also tried Imagemagick:
return spawn('convert', [tempFilePath + '[0]', '-quiet',`${tempFilePath}.jpg`]);
Also without any success.
Can anyone point me to the right direction here?
Upvotes: 22
Views: 9293
Reputation: 4190
@andrew-robinson post was a good start. The following will generate a thumbnail for both images and videos.
Add the following to your npm packages:
@ffmpeg-installer/ffmpeg
@google-cloud/storage
child-process-promise
mkdirp
mkdirp-promise
Use the following to generate a thumbnail from a larger image:
function generateFromImage(file, tempLocalThumbFile, fileName) {
const tempLocalFile = path.join(os.tmpdir(), fileName);
// Download file from bucket.
return file.download({destination: tempLocalFile}).then(() => {
console.info('The file has been downloaded to', tempLocalFile);
// Generate a thumbnail using ImageMagick with constant width and variable height (maintains ratio)
return spawn('convert', [tempLocalFile, '-thumbnail', THUMB_MAX_WIDTH, tempLocalThumbFile], {capture: ['stdout', 'stderr']});
}).then(() => {
fs.unlinkSync(tempLocalFile);
return Promise.resolve();
})
}
Use the following to generate a thumbnail from a video:
function generateFromVideo(file, tempLocalThumbFile) {
return file.getSignedUrl({action: 'read', expires: '05-24-2999'}).then((signedUrl) => {
const fileUrl = signedUrl[0];
const promise = spawn(ffmpegPath, ['-ss', '0', '-i', fileUrl, '-f', 'image2', '-vframes', '1', '-vf', `scale=${THUMB_MAX_WIDTH}:-1`, tempLocalThumbFile]);
// promise.childProcess.stdout.on('data', (data) => console.info('[spawn] stdout: ', data.toString()));
// promise.childProcess.stderr.on('data', (data) => console.info('[spawn] stderr: ', data.toString()));
return promise;
})
}
The following will execute when a video or image is uploaded to storage. It determines the file type, generates the thumbnail to a temp file, uploads the thumbnail to storage, then call 'updateDatabase()' which should be a promise that updates your database (if necessary):
const functions = require('firebase-functions');
const mkdirp = require('mkdirp-promise');
const gcs = require('@google-cloud/storage');
const admin = require('firebase-admin');
const spawn = require('child-process-promise').spawn;
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
const path = require('path');
const os = require('os');
const fs = require('fs');
const db = admin.firestore();
// Max height and width of the thumbnail in pixels.
const THUMB_MAX_WIDTH = 384;
const SERVICE_ACCOUNT = '<your firebase credentials file>.json';
const adminConfig = JSON.parse(process.env.FIREBASE_CONFIG);
module.exports = functions.storage.bucket(adminConfig.storageBucket).object().onFinalize(object => {
const fileBucket = object.bucket; // The Storage bucket that contains the file.
const filePathInBucket = object.name;
const resourceState = object.resourceState; // The resourceState is 'exists' or 'not_exists' (for file/folder deletions).
const metageneration = object.metageneration; // Number of times metadata has been generated. New objects have a value of 1.
const contentType = object.contentType; // This is the image MIME type
const isImage = contentType.startsWith('image/');
const isVideo = contentType.startsWith('video/');
// Exit if this is a move or deletion event.
if (resourceState === 'not_exists') {
return Promise.resolve();
}
// Exit if file exists but is not new and is only being triggered
// because of a metadata change.
else if (resourceState === 'exists' && metageneration > 1) {
return Promise.resolve();
}
// Exit if the image is already a thumbnail.
else if (filePathInBucket.indexOf('.thumbnail.') !== -1) {
return Promise.resolve();
}
// Exit if this is triggered on a file that is not an image or video.
else if (!(isImage || isVideo)) {
return Promise.resolve();
}
const fileDir = path.dirname(filePathInBucket);
const fileName = path.basename(filePathInBucket);
const fileInfo = parseName(fileName);
const thumbFileExt = isVideo ? 'jpg' : fileInfo.ext;
let thumbFilePath = path.normalize(path.join(fileDir, `${fileInfo.name}_${fileInfo.timestamp}.thumbnail.${thumbFileExt}`));
const tempLocalThumbFile = path.join(os.tmpdir(), thumbFilePath);
const tempLocalDir = path.join(os.tmpdir(), fileDir);
const generateOperation = isVideo ? generateFromVideo : generateFromImage;
// Cloud Storage files.
const bucket = gcs({keyFilename: SERVICE_ACCOUNT}).bucket(fileBucket);
const file = bucket.file(filePathInBucket);
const metadata = {
contentType: isVideo ? 'image/jpeg' : contentType,
// To enable Client-side caching you can set the Cache-Control headers here. Uncomment below.
// 'Cache-Control': 'public,max-age=3600',
};
// Create the temp directory where the storage file will be downloaded.
return mkdirp(tempLocalDir).then(() => {
return generateOperation(file, tempLocalThumbFile, fileName);
}).then(() => {
console.info('Thumbnail created at', tempLocalThumbFile);
// Get the thumbnail dimensions
return spawn('identify', ['-ping', '-format', '%wx%h', tempLocalThumbFile], {capture: ['stdout', 'stderr']});
}).then((result) => {
const dim = result.stdout.toString();
const idx = thumbFilePath.indexOf('.');
thumbFilePath = `${thumbFilePath.substring(0,idx)}_${dim}${thumbFilePath.substring(idx)}`;
console.info('Thumbnail dimensions:', dim);
// Uploading the Thumbnail.
return bucket.upload(tempLocalThumbFile, {destination: thumbFilePath, metadata: metadata});
}).then(() => {
console.info('Thumbnail uploaded to Storage at', thumbFilePath);
const thumbFilename = path.basename(thumbFilePath);
return updateDatabase(fileDir, fileName, thumbFilename);
}).then(() => {
console.info('Thumbnail generated.');
fs.unlinkSync(tempLocalThumbFile);
return Promise.resolve();
})
});
parseName() should parse your filename format. At the very least it should return the file's basename and extension.
updateDatabase() should return a promise that updates your database with the newly generated thumbnail (if necessary).
Note that @ffmpeg-installer/ffmpeg removes the need of directly including a ffmpeg binary in your cloud function.
Upvotes: 25
Reputation: 356
To use ffmpeg or any other system command-line tool that is not pre-installed on the firebase cloud function container, you can add a pre-compiled binary to the functions folder (alongside index.js) and it will upload it along with your cloud function code in the deploy step. You can then execute the binary using child-process-promise spawn as you were doing with ImageMagick (which is already installed).
You can get the ffmpeg binary here https://johnvansickle.com/ffmpeg/
I used the x86_64 build https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-64bit-static.tar.xz
Untar with
tar -xvzf ffmpeg-release-64bit-static.tar.xz
and just add the one ffmpeg file to the functions folder.
This link explains how you can extract the thumbnail from the video with just the url so there is no need to download the file fully. https://wistia.com/blog/faster-thumbnail-extraction-ffmpeg
The command to extract the thumbnail with width 512px and keeping the aspect ratio is
const spawn = require('child-process-promise').spawn;
const extractThumbnailFromVideoUrl = (fileUrl, tempThumbnailFilePath) => {
return spawn('./ffmpeg', ['-ss', '0', '-i', fileUrl, '-f', 'image2', '-vframes', '1', '-vf', 'scale=512:-1', tempThumbnailFilePath]);
};
Note the ./ in ./ffmpeg
For more details on the scale arguments you can see here https://trac.ffmpeg.org/wiki/Scaling%20(resizing)%20with%20ffmpeg
If the spawn command fails then as you have seen you will not get a very helpful error output. To get better output you can listen to the stdout and stderr event streams on the ChildProcess
const extractThumbnailFromVideoUrl = (fileUrl, tempThumbnailFilePath) => {
const promise = spawn('./ffmpeg', ['-ss', '0', '-i', fileUrl, '-f', 'image2', '-vframes', '1', '-vf', 'scale=512:-1', tempThumbnailFilePath]);
promise.childProcess.stdout.on('data', (data: any) => console.log('[spawn] stdout: ', data.toString()));
promise.childProcess.stderr.on('data', (data: any) => console.log('[spawn] stderr: ', data.toString()));
return promise;
};
The output of the ffmpeg call will then be displayed in your cloud function logs like they would if you ran the command locally from the terminal. For more info on that you can see https://www.npmjs.com/package/child-process-promise http://node.readthedocs.io/en/latest/api/child_process/
The following is a complete version of the cloud function assuming only video files. If you want to handle images or other files as well then you can add the code to exit early or call different methods as you were doing. This makes calls to create temp directories and cleans those directories up at the end of the method but I've omitted the details of those functions.
import * as functions from 'firebase-functions';
import * as gcs from '@google-cloud/storage';
import {cleanupFiles, makeTempDirectories} from '../services/system-utils';
const spawn = require('child-process-promise').spawn;
const storageProjectId = `${functions.config().project_id}.appspot.com`;
export const videoFileThumbnailGenerator = functions.storage.bucket(storageProjectId).object().onChange(event => {
const object = event.data;
const fileBucket = object.bucket; // The Storage bucket that contains the file.
const filePathInBucket = object.name; // File path in the bucket.
const resourceState = object.resourceState; // The resourceState is 'exists' or 'not_exists' (for file/folder deletions).
const metageneration = object.metageneration; // Number of times metadata has been generated. New objects have a value of 1.
// Exit if this is a move or deletion event.
if (resourceState === 'not_exists') {
console.log('This is a deletion event.');
return Promise.resolve();
}
// Exit if file exists but is not new and is only being triggered
// because of a metadata change.
if (resourceState === 'exists' && metageneration > 1) {
console.log('This is a metadata change event.');
return Promise.resolve();
}
const bucket = gcs({keyFilename: `${functions.config().firebase_admin_credentials}`}).bucket(fileBucket);
const filePathSplit = filePathInBucket.split('/');
const filename = filePathSplit.pop();
const filenameSplit = filename.split('.');
const fileExtension = filenameSplit.pop();
const baseFilename = filenameSplit.join('.');
const fileDir = filePathSplit.join('/') + (filePathSplit.length > 0 ? '/' : '');
const file = bucket.file(filePathInBucket);
const tempThumbnailDir = '/tmp/thumbnail/';
const jpgFilename = `${baseFilename}.jpg`;
const tempThumbnailFilePath = `${tempThumbnailDir}${jpgFilename}`;
const thumbnailFilePath = `${fileDir}thumbnail/${jpgFilename}`;
return makeTempDirectories([tempThumbnailDir])
.then(() => file.getSignedUrl({action: 'read', expires: '05-24-2999'}))
.then(signedUrl => signedUrl[0])
.then(fileUrl => extractThumbnailFromVideoUrl(fileUrl, tempThumbnailFilePath))
.then(() => bucket.upload(tempThumbnailFilePath, {destination: thumbnailFilePath}))
.then(() => cleanupFiles([
{directoryName: tempThumbnailFilePath},
]))
.catch(err => console.error('Video upload error: ', err));
});
const extractThumbnailFromVideoUrl = (fileUrl, tempThumbnailFilePath) => {
return spawn('./ffmpeg', ['-ss', '0', '-i', fileUrl, '-f', 'image2', '-vframes', '1', '-vf', 'scale=512:-1', tempThumbnailFilePath]);
};
Upvotes: 7