offload ffmpeg operations on custom worker
This commit is contained in:
parent
7b827bde68
commit
de174c2a37
109
src/services/ffmpeg/ffmpegClient.ts
Normal file
109
src/services/ffmpeg/ffmpegClient.ts
Normal 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;
|
84
src/services/ffmpeg/ffmpegService.ts
Normal file
84
src/services/ffmpeg/ffmpegService.ts
Normal 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();
|
|
@ -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();
|
|
@ -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;
|
||||
|
|
21
src/worker/ffmpeg.worker.js
Normal file
21
src/worker/ffmpeg.worker.js
Normal 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);
|
2
thirdparty/ffmpeg-wasm
vendored
2
thirdparty/ffmpeg-wasm
vendored
|
@ -1 +1 @@
|
|||
Subproject commit 0df4c85c38d2b8ddd714fe4418a86c23923da37d
|
||||
Subproject commit 8493ad48b12f83f881a59b84b003974ef23f9e96
|
Loading…
Reference in a new issue