feat: ffmpeg convert (#217)

This commit is contained in:
Artur Płaczek
2022-12-19 10:31:58 +01:00
committed by GitHub
parent cc220ac2f4
commit e74583f5b4
5 changed files with 8008 additions and 9899 deletions

17316
functions/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View 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'
);
});
});

View 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);
});
});
}

View File

@ -1,3 +1,4 @@
import * as admin from 'firebase-admin';
admin.initializeApp();
export { shareImage } from './share';
export { convert } from './convert';