mirror of
https://github.com/flutter/holobooth.git
synced 2025-05-17 13:25:59 +08:00
feat: ffmpeg convert (#217)
This commit is contained in:
17316
functions/package-lock.json
generated
17316
functions/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"main": "lib/index.js",
|
||||
"engines": {
|
||||
"node": "14"
|
||||
"node": "19"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
@ -22,24 +22,33 @@
|
||||
"watch": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"firebase-admin": "^9.6.0",
|
||||
"firebase-functions": "^3.13.2",
|
||||
"mustache": "^4.2.0"
|
||||
"busboy": "^1.6.0",
|
||||
"firebase-admin": "^10.3.0",
|
||||
"firebase-functions": "^4.1.0",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"fs": "^0.0.1-security",
|
||||
"mustache": "^4.2.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^26.0.22",
|
||||
"@types/mustache": "^4.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.21.0",
|
||||
"@typescript-eslint/parser": "^4.21.0",
|
||||
"eslint": "^7.23.0",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/mustache": "^4.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
"@typescript-eslint/parser": "^4.33.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jest": "^24.1.3",
|
||||
"jest": "^26.6.3",
|
||||
"prettier": "~2.2.1",
|
||||
"prettier-eslint": "~12.0.0",
|
||||
"ts-jest": "^26.5.4",
|
||||
"typescript": "^4.2.3"
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jest": "^24.7.0",
|
||||
"firebase-functions-test": "^3.0.0",
|
||||
"firebase-mock": "^2.3.2",
|
||||
"jest": "^29.3.1",
|
||||
"jest-mock-extended": "^3.0.1",
|
||||
"jest-when": "^3.5.2",
|
||||
"mocha": "^10.2.0",
|
||||
"prettier": "^2.8.1",
|
||||
"prettier-eslint": "^12.0.0",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
|
370
functions/src/convert/index.spec.ts
Normal file
370
functions/src/convert/index.spec.ts
Normal file
@ -0,0 +1,370 @@
|
||||
import * as convert from './index';
|
||||
import * as functions from 'firebase-functions';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import _busboy from 'busboy';
|
||||
import * as stream from 'stream';
|
||||
|
||||
const userId = 'test-user-id';
|
||||
const tempDir = 'test-temp-dir';
|
||||
|
||||
let mockReadStream;
|
||||
let mockWriteStream;
|
||||
let fileMakePublicFunction;
|
||||
let ffmpeg: ffmpeg;
|
||||
let busboy: _busboy;
|
||||
|
||||
function setUpMockReadStream(currentEvent: string) {
|
||||
return mockReadStream = {
|
||||
pipe: jest.fn().mockReturnThis(),
|
||||
end: jest.fn(),
|
||||
on: jest.fn((event, handler) => {
|
||||
if (currentEvent == event && event === 'error') {
|
||||
handler(Error());
|
||||
} else if (currentEvent == event && event === 'finish') {
|
||||
handler();
|
||||
} else if (currentEvent == event && event === 'end') {
|
||||
handler('file');
|
||||
}
|
||||
return mockReadStream;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function setUpMockWriteStream(currentEvent: string) {
|
||||
return mockWriteStream = {
|
||||
pipe: jest.fn().mockReturnThis(),
|
||||
once: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
close: jest.fn().mockReturnThis(),
|
||||
end: jest.fn(),
|
||||
on: jest.fn((event, handler) => {
|
||||
if (currentEvent == event && event === 'error') {
|
||||
handler(Error());
|
||||
} else if (currentEvent == event && event === 'end') {
|
||||
handler('file');
|
||||
} else if (currentEvent == event && event === 'close') {
|
||||
handler();
|
||||
}
|
||||
return mockWriteStream;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function setUpFileMakePublicFunction(returnValue) {
|
||||
fileMakePublicFunction = jest.fn(async () => {
|
||||
if (returnValue instanceof Error) {
|
||||
throw Error();
|
||||
} else {
|
||||
return [ returnValue ];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setUpFfmpeg(currentEvent: string) {
|
||||
ffmpeg = mockDeep<ffmpeg>({
|
||||
addInput: jest.fn().mockReturnThis(),
|
||||
addOptions: jest.fn().mockReturnThis(),
|
||||
inputFPS: jest.fn().mockReturnThis(),
|
||||
mergeToFile: jest.fn().mockReturnThis(),
|
||||
on: jest.fn((event, handler) => {
|
||||
if (currentEvent == event && event === 'error') {
|
||||
handler(Error());
|
||||
} else if (currentEvent == event && event === 'end') {
|
||||
handler();
|
||||
}
|
||||
return ffmpeg;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function setUpBusboy(currentEvent: string, fileEvent: string) {
|
||||
return busboy = mockDeep<_busboy>({
|
||||
end: jest.fn(),
|
||||
on: jest.fn((event, handler) => {
|
||||
setUpMockReadStream('finish');
|
||||
setUpMockWriteStream('close');
|
||||
|
||||
if (currentEvent == event && event === 'finish') {
|
||||
handler();
|
||||
} else if (currentEvent == event && event === 'error') {
|
||||
handler(Error());
|
||||
} else if (currentEvent == event && event === 'file') {
|
||||
handler('', setUpReadable(fileEvent), { filename: 'filename' });
|
||||
busboy = setUpBusboy('finish', fileEvent);
|
||||
}
|
||||
return busboy;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function setUpReadable(currentEvent: string) {
|
||||
const readable = mockDeep<stream.Readable>({
|
||||
pipe: jest.fn().mockReturnThis(),
|
||||
on: jest.fn((event, handler) => {
|
||||
if (currentEvent == event && event === 'error') {
|
||||
handler(Error());
|
||||
} else if (currentEvent == event && event === 'end') {
|
||||
handler('file');
|
||||
} else if (currentEvent == event && event === 'close') {
|
||||
handler();
|
||||
}
|
||||
return readable;
|
||||
}),
|
||||
});
|
||||
return readable;
|
||||
}
|
||||
|
||||
|
||||
jest.mock('busboy', () => () => {
|
||||
return {
|
||||
end: jest.fn(),
|
||||
on: jest.fn((event, handler) =>{
|
||||
setUpMockReadStream('finish');
|
||||
|
||||
if (event === 'finish') {
|
||||
handler('done');
|
||||
}
|
||||
return busboy;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('fluent-ffmpeg', () => () => {
|
||||
return {
|
||||
addInput: jest.fn().mockReturnThis(),
|
||||
addOptions: jest.fn().mockReturnThis(),
|
||||
inputFPS: jest.fn().mockReturnThis(),
|
||||
mergeToFile: jest.fn().mockReturnThis(),
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') {
|
||||
handler();
|
||||
}
|
||||
return ffmpeg;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('fs', () => {
|
||||
return {
|
||||
mkdtempSync: jest.fn(() => `${tempDir}/${userId}`),
|
||||
createReadStream: jest.fn(() => mockReadStream),
|
||||
createWriteStream: jest.fn(() => mockWriteStream),
|
||||
stat: jest.fn(),
|
||||
unlinkSync: jest.fn(),
|
||||
rmdir: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('os', () => {
|
||||
return {
|
||||
tmpdir: jest.fn(() => tempDir),
|
||||
platform: jest.fn(() => ''),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('firebase-admin', () => {
|
||||
return {
|
||||
storage: jest.fn(() => ({
|
||||
bucket: jest.fn(() => ({
|
||||
name: 'test-bucket',
|
||||
file: jest.fn(() => ({
|
||||
name: 'test-file',
|
||||
exists: jest.fn(async () => {
|
||||
return [ true ];
|
||||
}),
|
||||
createWriteStream: jest.fn(() => mockWriteStream),
|
||||
makePublic: fileMakePublicFunction,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('convert', () => {
|
||||
it('returns response with status 200 on success', async () => {
|
||||
const mockRequest = mockDeep<functions.https.Request>();
|
||||
mockRequest.get.mockReturnValue('localhost:5001');
|
||||
mockRequest.protocol = 'https';
|
||||
setUpBusboy('finish', 'end');
|
||||
setUpFfmpeg('end');
|
||||
setUpMockReadStream('finish');
|
||||
setUpFileMakePublicFunction(true);
|
||||
|
||||
const mockResponse = mockDeep<functions.Response>({
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
});
|
||||
|
||||
await convert.convert(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(200);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith(
|
||||
'https://storage.googleapis.com/test-bucket/test-file'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns status 500 on error', async () => {
|
||||
const mockRequest = mockDeep<functions.https.Request>();
|
||||
mockRequest.get.mockReturnValue('localhost:5001');
|
||||
mockRequest.protocol = 'https';
|
||||
setUpBusboy('error', 'error');
|
||||
|
||||
|
||||
const mockResponse = mockDeep<functions.Response>({
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
});
|
||||
await convert.convert(mockRequest, mockResponse);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('convertImages', () => {
|
||||
it('throws error on bad host', async () => {
|
||||
const mockRequest = mockDeep<functions.https.Request>();
|
||||
mockRequest.protocol ='https';
|
||||
|
||||
await expect(convert.convertImages(mockRequest)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('returns response with status 200 and file url on success', async () => {
|
||||
const mockRequest = mockDeep<functions.https.Request>();
|
||||
mockRequest.get.mockReturnValue('localhost:5001');
|
||||
mockRequest.protocol = 'https';
|
||||
|
||||
setUpBusboy('finish', 'end');
|
||||
setUpFfmpeg('end');
|
||||
setUpMockReadStream('finish');
|
||||
setUpFileMakePublicFunction(true);
|
||||
|
||||
const { status, url } = await convert.convertImages(mockRequest);
|
||||
expect(status).toEqual(200);
|
||||
expect(url).toEqual('https://storage.googleapis.com/test-bucket/test-file');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('createTempDirectory', () => {
|
||||
it('returns path to temp directory', async () => {
|
||||
const testPath = tempDir + '/' + userId;
|
||||
|
||||
const expectedPath = await convert.createTempDirectory(userId);
|
||||
expect(expectedPath).toEqual(testPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFramesFromRequest', () => {
|
||||
const mockRequest = mockDeep<functions.https.Request>();
|
||||
|
||||
it('returns list of frames for request', async () => {
|
||||
setUpBusboy('file', 'close');
|
||||
|
||||
await expect(
|
||||
convert.readFramesFromRequest(busboy, mockRequest, tempDir)
|
||||
).resolves.toStrictEqual([ 'test-temp-dir/filename' ]);
|
||||
});
|
||||
|
||||
|
||||
it('throws error when unable to read frames.', async () => {
|
||||
setUpBusboy('error', 'error');
|
||||
|
||||
await expect(
|
||||
convert.readFramesFromRequest(busboy, mockRequest, tempDir)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('proceedFile', () => {
|
||||
const filePath = `${tempDir}/frame_1.png`;
|
||||
|
||||
describe('returns path for the file', () => {
|
||||
it('on close', async () => {
|
||||
setUpMockWriteStream('close');
|
||||
|
||||
await expect(
|
||||
convert.proceedFile(filePath, setUpReadable('close'))
|
||||
).resolves.toBe(filePath);
|
||||
});
|
||||
|
||||
it('on end', async () => {
|
||||
setUpMockWriteStream('end');
|
||||
|
||||
await expect(convert.proceedFile(filePath, setUpReadable('end'))).resolves.toBe(filePath);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('rejects on error', () => {
|
||||
it('in file', async () => {
|
||||
const files = convert.proceedFile(filePath, setUpReadable('error'));
|
||||
|
||||
await expect(files).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('in write stream', async () => {
|
||||
setUpMockWriteStream('error');
|
||||
const files = convert.proceedFile(filePath, setUpReadable('end'));
|
||||
|
||||
await expect(files).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('throws error when unable to read file.', async () => {
|
||||
setUpMockWriteStream('error');
|
||||
|
||||
await expect(
|
||||
convert.proceedFile(filePath, setUpReadable('end'))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertToVideo', () => {
|
||||
it('returns path for the file', async () => {
|
||||
setUpFfmpeg('end');
|
||||
|
||||
await expect(
|
||||
convert.convertToVideo(ffmpeg, [ `${tempDir}/frame_1.png` ], tempDir)
|
||||
).resolves.toBe(`${tempDir}/video.mp4`);
|
||||
});
|
||||
|
||||
it('throws error when unable to convert frames.', async () => {
|
||||
setUpFfmpeg('error');
|
||||
|
||||
await expect(
|
||||
convert. convertToVideo(ffmpeg, [ `${tempDir}/frame_1.png` ], tempDir)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
const videoName = 'test-video-name';
|
||||
const videoPath = 'test-path';
|
||||
|
||||
it('throws error when unable to write file to storage.', async () => {
|
||||
setUpMockReadStream('error');
|
||||
|
||||
await expect(convert.uploadFile(videoName, videoPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws error when make a file public throws.', async () => {
|
||||
setUpMockReadStream('finish');
|
||||
setUpFileMakePublicFunction(Error());
|
||||
|
||||
await expect(convert.uploadFile(videoName, videoPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('returns path for the file.', async () => {
|
||||
setUpMockReadStream('finish');
|
||||
setUpFileMakePublicFunction(true);
|
||||
|
||||
await expect(convert.uploadFile(videoName, videoPath)).resolves.toBe(
|
||||
'https://storage.googleapis.com/test-bucket/test-file'
|
||||
);
|
||||
});
|
||||
});
|
179
functions/src/convert/index.ts
Normal file
179
functions/src/convert/index.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import * as admin from 'firebase-admin';
|
||||
import * as functions from 'firebase-functions';
|
||||
import * as path from 'path';
|
||||
|
||||
import { UPLOAD_PATH, ALLOWED_HOSTS } from '../config';
|
||||
import ffmpeg from 'fluent-ffmpeg';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import _busboy from 'busboy';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export const errorMessage = 'Something went wrong';
|
||||
|
||||
/**
|
||||
* Public convert function
|
||||
*/
|
||||
export const convert = functions.https.onRequest(async (req, res) => {
|
||||
try {
|
||||
const { status, url } = await convertImages(req);
|
||||
|
||||
res.set('Access-Control-Allow-Origin', '*');
|
||||
res.status(status).send(url);
|
||||
} catch (error) {
|
||||
functions.logger.error(error);
|
||||
res.status(500).send(errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
export async function convertImages(
|
||||
req: functions.https.Request,
|
||||
): Promise<{ status: number; url: string }> {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
try {
|
||||
const host = req.get('host') ?? '';
|
||||
const baseUrl = `${req.protocol}://${host}`;
|
||||
|
||||
if (!ALLOWED_HOSTS.includes(host)) {
|
||||
functions.logger.log('Bad host', {
|
||||
host,
|
||||
baseUrl,
|
||||
});
|
||||
throw new Error('Bad host');
|
||||
}
|
||||
|
||||
const userId = uuidv4();
|
||||
tempDir = await createTempDirectory(userId);
|
||||
const busboy = _busboy({ headers: req.headers });
|
||||
const frames = await readFramesFromRequest(busboy, req, tempDir);
|
||||
const videoPath = await convertToVideo(ffmpeg(), frames, tempDir);
|
||||
const url = await uploadFile(userId + '.mp4', videoPath);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
url: url,
|
||||
};
|
||||
} catch (error) {
|
||||
functions.logger.error(error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (tempDir != null) {
|
||||
fs.rmdir(tempDir, () => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTempDirectory(userId: string): Promise<string> {
|
||||
const tmpdir = os.tmpdir();
|
||||
return fs.mkdtempSync(path.join(tmpdir, userId));
|
||||
}
|
||||
|
||||
|
||||
export async function readFramesFromRequest(
|
||||
busboy: _busboy,
|
||||
req: functions.https.Request,
|
||||
folder: string
|
||||
): Promise<string[]> {
|
||||
const paths: string[] = [];
|
||||
const fileWrites =new Map<string, Promise<string>>();
|
||||
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
busboy
|
||||
.on('file', (_, file, { filename }) => {
|
||||
const filepath = path.join(folder, filename);
|
||||
paths.push(filepath);
|
||||
fileWrites[filepath] = proceedFile(filepath, file);
|
||||
})
|
||||
.on('finish', async () => {
|
||||
await Promise.all([ ...fileWrites.values() ]);
|
||||
resolve(paths);
|
||||
})
|
||||
.on('error', function(error) {
|
||||
functions.logger.error(error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
busboy.end(req.rawBody);
|
||||
});
|
||||
}
|
||||
|
||||
export async function proceedFile(
|
||||
filepath: string,
|
||||
file: Readable
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const writeStream = fs.createWriteStream(filepath);
|
||||
file.pipe(writeStream)
|
||||
.on('error', (error) => {
|
||||
functions.logger.error(error);
|
||||
reject(error);
|
||||
})
|
||||
.on('end', () => {
|
||||
writeStream.end();
|
||||
});
|
||||
|
||||
writeStream
|
||||
.on('end', () => resolve(filepath))
|
||||
.on('close', () => resolve(filepath))
|
||||
.on('error', function(error) {
|
||||
functions.logger.error(error);
|
||||
return reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function convertToVideo(
|
||||
ffmpeg: ffmpeg,
|
||||
frames: string[],
|
||||
folder: string
|
||||
): Promise<string> {
|
||||
const videoPath = `${folder}/video.mp4`;
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg
|
||||
.addInput(folder + '/frame_%d.png')
|
||||
.addOptions([ '-codec:v libx264', '-pix_fmt yuv420p' ])
|
||||
.inputFPS(frames.length / 5)
|
||||
.mergeToFile(videoPath)
|
||||
.on('end', () => {
|
||||
frames.forEach((frame) => {
|
||||
fs.unlinkSync(frame);
|
||||
});
|
||||
resolve(videoPath);
|
||||
})
|
||||
.on('error', function(error) {
|
||||
functions.logger.error(error);
|
||||
return reject( error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadFile(
|
||||
videoName: string,
|
||||
path: string
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bucket = admin.storage().bucket();
|
||||
const file = bucket.file(`${UPLOAD_PATH}/${videoName}`);
|
||||
|
||||
fs.createReadStream(path)
|
||||
.pipe(file.createWriteStream())
|
||||
.on('finish', function() {
|
||||
file
|
||||
.makePublic()
|
||||
.then(() => {
|
||||
resolve(
|
||||
`https://storage.googleapis.com/${bucket.name}/${file.name}`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(new Error(error.message));
|
||||
});
|
||||
})
|
||||
.on('error', function(error) {
|
||||
functions.logger.error(error);
|
||||
return reject(error);
|
||||
});
|
||||
});
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import * as admin from 'firebase-admin';
|
||||
admin.initializeApp();
|
||||
export { shareImage } from './share';
|
||||
export { convert } from './convert';
|
||||
|
Reference in New Issue
Block a user