offload ffmpeg operations on custom worker

This commit is contained in:
Abhinav 2022-03-07 18:48:46 +05:30
parent 7b827bde68
commit de174c2a37
6 changed files with 228 additions and 232 deletions

View file

@ -0,0 +1,109 @@
import { createFFmpeg, FFmpeg } from 'ffmpeg-wasm';
import { getUint8ArrayView } from 'services/readerService';
import { parseFFmpegExtractedMetadata } from 'utils/ffmpeg';
class FFmpegClient {
private ffmpeg: FFmpeg;
private fileReader: FileReader;
constructor() {
this.ffmpeg = createFFmpeg({
corePath: '/js/ffmpeg/ffmpeg-core.js',
log: true,
mt: false,
});
}
private async ready() {
if (!this.ffmpeg.isLoaded()) {
await this.ffmpeg.load();
}
if (!this.fileReader) {
this.fileReader = new FileReader();
}
}
async generateThumbnail(file: File) {
await this.ready();
const inputFileName = `${Date.now().toString()}-${file.name}`;
const thumbFileName = `${Date.now().toString()}-thumb.jpeg`;
this.ffmpeg.FS(
'writeFile',
inputFileName,
await getUint8ArrayView(this.fileReader, file)
);
let seekTime = 1.0;
let thumb = null;
while (seekTime > 0) {
try {
await this.ffmpeg.run(
'-i',
inputFileName,
'-ss',
`00:00:0${seekTime.toFixed(3)}`,
'-vframes',
'1',
'-vf',
'scale=-1:720',
thumbFileName
);
thumb = this.ffmpeg.FS('readFile', thumbFileName);
this.ffmpeg.FS('unlink', thumbFileName);
break;
} catch (e) {
seekTime = Number((seekTime / 10).toFixed(3));
}
}
this.ffmpeg.FS('unlink', inputFileName);
return thumb;
}
async extractVideoMetadata(file: File) {
await this.ready();
const inputFileName = `${Date.now().toString()}-${file.name}`;
const outFileName = `${Date.now().toString()}-metadata.txt`;
this.ffmpeg.FS(
'writeFile',
inputFileName,
await getUint8ArrayView(this.fileReader, file)
);
let metadata = null;
// https://stackoverflow.com/questions/9464617/retrieving-and-saving-media-metadata-using-ffmpeg
// -c [short for codex] copy[(stream_specifier)[ffmpeg.org/ffmpeg.html#Stream-specifiers]] => copies all the stream without re-encoding
// -map_metadata [http://ffmpeg.org/ffmpeg.html#Advanced-options search for map_metadata] => copies all stream metadata to the out
// -f ffmetadata [https://ffmpeg.org/ffmpeg-formats.html#Metadata-1] => dump metadata from media files into a simple UTF-8-encoded INI-like text file
await this.ffmpeg.run(
'-i',
inputFileName,
'-c',
'copy',
'-map_metadata',
'0',
'-f',
'ffmetadata',
outFileName
);
metadata = this.ffmpeg.FS('readFile', outFileName);
this.ffmpeg.FS('unlink', outFileName);
this.ffmpeg.FS('unlink', inputFileName);
return parseFFmpegExtractedMetadata(metadata);
}
async convertToMP4(file: Uint8Array, inputFileName: string) {
await this.ready();
this.ffmpeg.FS('writeFile', inputFileName, file);
await this.ffmpeg.run(
'-i',
inputFileName,
'-preset',
'ultrafast',
'output.mp4'
);
const convertedFile = this.ffmpeg.FS('readFile', 'output.mp4');
this.ffmpeg.FS('unlink', inputFileName);
this.ffmpeg.FS('unlink', 'output.mp4');
return convertedFile;
}
}
export default FFmpegClient;

View file

@ -0,0 +1,84 @@
import { CustomError } from 'utils/error';
import { logError } from 'utils/sentry';
import QueueProcessor from 'services/queueProcessor';
import { ParsedExtractedMetadata } from 'types/upload';
import { FFmpegWorker } from 'utils/comlink';
class FFmpegService {
private ffmpegWorker = null;
private ffmpegTaskQueue = new QueueProcessor<any>(1);
async init() {
this.ffmpegWorker = await new FFmpegWorker();
}
async generateThumbnail(file: File): Promise<Uint8Array> {
if (!this.ffmpegWorker) {
await this.init();
}
const response = this.ffmpegTaskQueue.queueUpRequest(
async () => await this.ffmpegWorker.generateThumbnail(file)
);
try {
return await response.promise;
} catch (e) {
if (e.message === CustomError.REQUEST_CANCELLED) {
// ignore
return null;
} else {
logError(e, 'ffmpeg thumbnail generation failed');
throw e;
}
}
}
async extractMetadata(file: File): Promise<ParsedExtractedMetadata> {
if (!this.ffmpegWorker) {
await this.init();
}
const response = this.ffmpegTaskQueue.queueUpRequest(
async () => await this.ffmpegWorker.extractVideoMetadata(file)
);
try {
return await response.promise;
} catch (e) {
if (e.message === CustomError.REQUEST_CANCELLED) {
// ignore
return null;
} else {
logError(e, 'ffmpeg metadata extraction failed');
throw e;
}
}
}
async convertToMP4(
file: Uint8Array,
fileName: string
): Promise<Uint8Array> {
if (!this.ffmpegWorker) {
await this.init();
}
const response = this.ffmpegTaskQueue.queueUpRequest(
async () => await this.ffmpegWorker.convertToMP4(file, fileName)
);
try {
return await response.promise;
} catch (e) {
if (e.message === CustomError.REQUEST_CANCELLED) {
// ignore
return null;
} else {
logError(e, 'ffmpeg MP4 conversion failed');
throw e;
}
}
}
}
export default new FFmpegService();

View file

@ -1,231 +0,0 @@
import { createFFmpeg, FFmpeg } from 'ffmpeg-wasm';
import { CustomError } from 'utils/error';
import { logError } from 'utils/sentry';
import QueueProcessor from './queueProcessor';
import { ParsedExtractedMetadata } from 'types/upload';
import { getUint8ArrayView } from 'services/readerService';
import { parseFFmpegExtractedMetadata } from './upload/videoMetadataService';
class FFmpegService {
private ffmpeg: FFmpeg = null;
private isLoading = null;
private fileReader: FileReader = null;
private ffmpegTaskQueue = new QueueProcessor<any>(1);
async init() {
try {
this.ffmpeg = createFFmpeg({
corePath: '/js/ffmpeg/ffmpeg-core.js',
mt: false,
});
this.isLoading = this.ffmpeg.load();
await this.isLoading;
this.isLoading = null;
} catch (e) {
logError(e, 'ffmpeg load failed');
this.ffmpeg = null;
this.isLoading = null;
throw e;
}
}
async generateThumbnail(file: File): Promise<Uint8Array> {
if (!this.ffmpeg) {
await this.init();
}
if (!this.fileReader) {
this.fileReader = new FileReader();
}
if (this.isLoading) {
await this.isLoading;
}
const response = this.ffmpegTaskQueue.queueUpRequest(
generateThumbnailHelper.bind(
null,
this.ffmpeg,
this.fileReader,
file
)
);
try {
return await response.promise;
} catch (e) {
if (e.message === CustomError.REQUEST_CANCELLED) {
// ignore
return null;
} else {
logError(e, 'ffmpeg thumbnail generation failed');
throw e;
}
}
}
async extractMetadata(file: File): Promise<ParsedExtractedMetadata> {
if (!this.ffmpeg) {
await this.init();
}
if (!this.fileReader) {
this.fileReader = new FileReader();
}
if (this.isLoading) {
await this.isLoading;
}
const response = this.ffmpegTaskQueue.queueUpRequest(
extractVideoMetadataHelper.bind(
null,
this.ffmpeg,
this.fileReader,
file
)
);
try {
return await response.promise;
} catch (e) {
if (e.message === CustomError.REQUEST_CANCELLED) {
// ignore
return null;
} else {
logError(e, 'ffmpeg metadata extraction failed');
throw e;
}
}
}
async convertToMP4(
file: Uint8Array,
fileName: string
): Promise<Uint8Array> {
if (!this.ffmpeg) {
await this.init();
}
if (this.isLoading) {
await this.isLoading;
}
const response = this.ffmpegTaskQueue.queueUpRequest(
convertToMP4Helper.bind(null, this.ffmpeg, file, fileName)
);
try {
return await response.promise;
} catch (e) {
if (e.message === CustomError.REQUEST_CANCELLED) {
// ignore
return null;
} else {
logError(e, 'ffmpeg MP4 conversion failed');
throw e;
}
}
}
}
async function generateThumbnailHelper(
ffmpeg: FFmpeg,
reader: FileReader,
file: File
) {
try {
const inputFileName = `${Date.now().toString()}-${file.name}`;
const thumbFileName = `${Date.now().toString()}-thumb.jpeg`;
ffmpeg.FS(
'writeFile',
inputFileName,
await getUint8ArrayView(reader, file)
);
let seekTime = 1.0;
let thumb = null;
while (seekTime > 0) {
try {
await ffmpeg.run(
'-i',
inputFileName,
'-ss',
`00:00:0${seekTime.toFixed(3)}`,
'-vframes',
'1',
'-vf',
'scale=-1:720',
thumbFileName
);
thumb = ffmpeg.FS('readFile', thumbFileName);
ffmpeg.FS('unlink', thumbFileName);
break;
} catch (e) {
seekTime = Number((seekTime / 10).toFixed(3));
}
}
ffmpeg.FS('unlink', inputFileName);
return thumb;
} catch (e) {
logError(e, 'ffmpeg thumbnail generation failed');
throw e;
}
}
async function extractVideoMetadataHelper(
ffmpeg: FFmpeg,
reader: FileReader,
file: File
) {
try {
const inputFileName = `${Date.now().toString()}-${file.name}`;
const outFileName = `${Date.now().toString()}-metadata.txt`;
ffmpeg.FS(
'writeFile',
inputFileName,
await getUint8ArrayView(reader, file)
);
let metadata = null;
// https://stackoverflow.com/questions/9464617/retrieving-and-saving-media-metadata-using-ffmpeg
// -c [short for codex] copy[(stream_specifier)[ffmpeg.org/ffmpeg.html#Stream-specifiers]] => copies all the stream without re-encoding
// -map_metadata [http://ffmpeg.org/ffmpeg.html#Advanced-options search for map_metadata] => copies all stream metadata to the out
// -f ffmetadata [https://ffmpeg.org/ffmpeg-formats.html#Metadata-1] => dump metadata from media files into a simple UTF-8-encoded INI-like text file
await ffmpeg.run(
'-i',
inputFileName,
'-c',
'copy',
'-map_metadata',
'0',
'-f',
'ffmetadata',
outFileName
);
metadata = ffmpeg.FS('readFile', outFileName);
ffmpeg.FS('unlink', outFileName);
ffmpeg.FS('unlink', inputFileName);
return parseFFmpegExtractedMetadata(metadata);
} catch (e) {
logError(e, 'ffmpeg metadata extraction failed');
throw e;
}
}
async function convertToMP4Helper(
ffmpeg: FFmpeg,
file: Uint8Array,
inputFileName: string
) {
try {
ffmpeg.FS('writeFile', inputFileName, file);
await ffmpeg.run(
'-i',
inputFileName,
'-preset',
'ultrafast',
'output.mp4'
);
const convertedFile = ffmpeg.FS('readFile', 'output.mp4');
ffmpeg.FS('unlink', inputFileName);
ffmpeg.FS('unlink', 'output.mp4');
return convertedFile;
} catch (e) {
logError(e, 'ffmpeg MP4 conversion failed');
throw e;
}
}
export default new FFmpegService();

View file

@ -16,4 +16,17 @@ export const getDedicatedConvertWorker = (): ComlinkWorker => {
return { comlink, worker };
}
};
const getDedicatedFFmpegWorker = (): ComlinkWorker => {
if (runningInBrowser()) {
const worker = new Worker(
new URL('worker/ffmpeg.worker.js', import.meta.url),
{ name: 'ente-ffmpeg-worker' }
);
const comlink = Comlink.wrap(worker);
return { comlink, worker };
}
};
export const ConvertWorker: any = getDedicatedConvertWorker()?.comlink;
export const FFmpegWorker: any = getDedicatedFFmpegWorker()?.comlink;

View file

@ -0,0 +1,21 @@
import * as Comlink from 'comlink';
import FFmpegClient from 'services/ffmpeg/FFmpegClient';
export class FFmpeg {
ffmpegClient;
constructor() {
this.ffmpegClient = new FFmpegClient();
}
async generateThumbnail(file) {
return this.ffmpegClient.generateThumbnail(file);
}
async extractVideoMetadata(file) {
return this.ffmpegClient.extractVideoMetadata(file);
}
async convertToMP4(file, inputFileName) {
return this.ffmpegClient.convertToMP4(file, inputFileName);
}
}
Comlink.expose(FFmpeg);

@ -1 +1 @@
Subproject commit 0df4c85c38d2b8ddd714fe4418a86c23923da37d
Subproject commit 8493ad48b12f83f881a59b84b003974ef23f9e96