Complete major refactor

This commit is contained in:
Jerome Wu 2020-11-03 15:36:44 +08:00
parent 25f37fa00b
commit 265cf4c580
21 changed files with 248 additions and 239 deletions

View File

@ -1,13 +1,11 @@
const resolveURL = require('resolve-url'); const resolveURL = require('resolve-url');
const { dependencies } = require('../../package.json'); const { devDependencies } = require('../../package.json');
const defaultOptions = require('../constants/defaultOptions');
/* /*
* Default options for browser worker * Default options for browser environment
*/ */
module.exports = { module.exports = {
...defaultOptions,
corePath: (typeof process !== 'undefined' && process.env.FFMPEG_ENV === 'development') corePath: (typeof process !== 'undefined' && process.env.FFMPEG_ENV === 'development')
? resolveURL('/node_modules/@ffmpeg/core/ffmpeg-core.js') ? resolveURL('/node_modules/@ffmpeg/core/dist/ffmpeg-core.js')
: `https://unpkg.com/@ffmpeg/core@v${dependencies['@ffmpeg/core'].substring(1)}/ffmpeg-core.js`, : `https://unpkg.com/@ffmpeg/core@v${devDependencies['@ffmpeg/core'].substring(1)}/ffmpeg-core.js`,
}; };

View File

@ -1,12 +1,5 @@
const resolveURL = require('resolve-url'); const resolveURL = require('resolve-url');
/**
* readFromBlobOrFile
*
* @name readFromBlobOrFile
* @function
* @access private
*/
const readFromBlobOrFile = (blob) => ( const readFromBlobOrFile = (blob) => (
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const fileReader = new FileReader(); const fileReader = new FileReader();
@ -23,19 +16,21 @@ const readFromBlobOrFile = (blob) => (
module.exports = async (_data) => { module.exports = async (_data) => {
let data = _data; let data = _data;
if (typeof _data === 'undefined') { if (typeof _data === 'undefined') {
return 'undefined'; return new Uint8Array();
} }
if (typeof _data === 'string') { if (typeof _data === 'string') {
// Base64 _data /* From base64 format */
if (/data:_data\/([a-zA-Z]*);base64,([^"]*)/.test(_data)) { if (/data:_data\/([a-zA-Z]*);base64,([^"]*)/.test(_data)) {
data = atob(_data.split(',')[1]) data = atob(_data.split(',')[1])
.split('') .split('')
.map((c) => c.charCodeAt(0)); .map((c) => c.charCodeAt(0));
/* From remote server/URL */
} else { } else {
const res = await fetch(resolveURL(_data)); const res = await fetch(resolveURL(_data));
data = await res.arrayBuffer(); data = await res.arrayBuffer();
} }
/* From Blob or File */
} else if (_data instanceof File || _data instanceof Blob) { } else if (_data instanceof File || _data instanceof Blob) {
data = await readFromBlobOrFile(_data); data = await readFromBlobOrFile(_data);
} }

View File

@ -1,8 +1,10 @@
const resolveURL = require('resolve-url');
const { log } = require('../utils/log'); const { log } = require('../utils/log');
module.exports = async ({ corePath }) => { module.exports = async ({ corePath: _corePath }) => {
if (typeof window.Module === 'undefined') { if (typeof window.createFFmpegCore === 'undefined') {
log('info', 'fetch ffmpeg-core.worker.js script'); log('info', 'fetch ffmpeg-core.worker.js script');
const corePath = resolveURL(_corePath);
const workerBlob = await (await fetch(corePath.replace('ffmpeg-core.js', 'ffmpeg-core.worker.js'))).blob(); const workerBlob = await (await fetch(corePath.replace('ffmpeg-core.js', 'ffmpeg-core.worker.js'))).blob();
window.FFMPEG_CORE_WORKER_SCRIPT = URL.createObjectURL(workerBlob); window.FFMPEG_CORE_WORKER_SCRIPT = URL.createObjectURL(workerBlob);
log('info', `worker object URL=${window.FFMPEG_CORE_WORKER_SCRIPT}`); log('info', `worker object URL=${window.FFMPEG_CORE_WORKER_SCRIPT}`);
@ -12,10 +14,7 @@ module.exports = async ({ corePath }) => {
const eventHandler = () => { const eventHandler = () => {
script.removeEventListener('load', eventHandler); script.removeEventListener('load', eventHandler);
log('info', 'initialize ffmpeg-core'); log('info', 'initialize ffmpeg-core');
window.Module.onRuntimeInitialized = () => { resolve(window.createFFmpegCore);
log('info', 'ffmpeg-core initialized');
resolve(window.Module);
};
}; };
script.src = corePath; script.src = corePath;
script.type = 'text/javascript'; script.type = 'text/javascript';
@ -24,5 +23,5 @@ module.exports = async ({ corePath }) => {
}); });
} }
log('info', 'ffmpeg-core is loaded already'); log('info', 'ffmpeg-core is loaded already');
return Promise.resolve(window.Module); return Promise.resolve(window.createFFmpegCore);
}; };

View File

@ -1,9 +1,9 @@
const defaultOptions = require('./defaultOptions'); const defaultOptions = require('./defaultOptions');
const getModule = require('./getModule'); const getCreateFFmpegCore = require('./getCreateFFmpegCore');
const fetchFile = require('./fetchFile'); const fetchFile = require('./fetchFile');
module.exports = { module.exports = {
defaultOptions, defaultOptions,
getModule, getCreateFFmpegCore,
fetchFile, fetchFile,
}; };

52
src/config.js Normal file
View File

@ -0,0 +1,52 @@
module.exports = {
defaultArgs: [
/* args[0] is always the binary path */
'./ffmpeg',
/* Disable interaction mode */
'-nostdin',
/* Force to override output file */
'-y',
/* Not to output banner */
'-hide_banner',
],
baseOptions: {
/* Flag to turn on/off log messages in console */
log: false,
/*
* Custom logger to get ffmpeg.wasm output messages.
* a sample logger looks like this:
*
* ```
* logger = ({ type, message }) => {
* console.log(type, message);
* }
* ```
*
* type can be one of following:
*
* info: internal workflow debug messages
* fferr: ffmpeg native stderr output
* ffout: ffmpeg native stdout output
*/
logger: () => {},
/*
* Progress handler to get current progress of ffmpeg command.
* a sample progress handler looks like this:
*
* ```
* progress = ({ ratio }) => {
* console.log(ratio);
* }
* ```
*
* ratio is a float number between 0 to 1.
*/
progress: () => {},
/*
* Path to find/download ffmpeg.wasm-core,
* this value should be overwriten by `defaultOptions` in
* each environment.
*/
corePath: '',
},
};

View File

@ -1,5 +0,0 @@
module.exports = [
'./ffmpeg', // args[0] is always binary path
'-nostdin', // Disable interaction mode
'-hide_banner', // Not to output banner
];

View File

@ -1,5 +0,0 @@
module.exports = {
log: false,
logger: () => {},
progress: () => {},
};

View File

@ -1,21 +1,14 @@
const defaultArgs = require('./constants/defaultArgs'); const { defaultArgs, baseOptions } = require('./config');
const { setLogging, log } = require('./utils/log'); const { setLogging, setCustomLogger, log } = require('./utils/log');
const resolvePaths = require('./utils/resolvePaths');
const parseProgress = require('./utils/parseProgress'); const parseProgress = require('./utils/parseProgress');
const stringList2pointer = require('./utils/stringList2pointer');
const parseArgs = require('./utils/parseArgs'); const parseArgs = require('./utils/parseArgs');
const { const { defaultOptions, getCreateFFmpegCore } = require('./node');
defaultOptions,
getModule,
fetchFile,
} = require('./node');
const NO_LOAD = Error('FFmpeg.js is not ready, make sure you have completed load().'); const NO_LOAD = Error('ffmpeg.wasm is not ready, make sure you have completed load().');
const NO_MULTIPLE_RUN = Error('FFmpeg.js can only run one command at a time');
let Module = null;
let ffmpeg = null;
module.exports = (_options = {}) => { module.exports = (_options = {}) => {
let Core = null;
let ffmpeg = null;
let runResolve = null; let runResolve = null;
let running = false; let running = false;
const { const {
@ -23,109 +16,134 @@ module.exports = (_options = {}) => {
logger, logger,
progress, progress,
...options ...options
} = resolvePaths({ } = {
...baseOptions,
...defaultOptions, ...defaultOptions,
..._options, ..._options,
}); };
const detectCompletion = ({ message, type }) => { const detectCompletion = (message) => {
if (type === 'ffmpeg-stdout' if (message === 'FFMPEG_END' && runResolve !== null) {
&& message === 'FFMPEG_END'
&& runResolve !== null) {
runResolve(); runResolve();
runResolve = null; runResolve = null;
running = false; running = false;
} }
}; };
const parseMessage = ({ type, message }) => {
log(type, message);
parseProgress(message, progress);
detectCompletion(message);
};
setLogging(logging); /*
* Load ffmpeg.wasm-core script.
* In browser environment, the ffmpeg.wasm-core script is fetch from
* CDN and can be assign to a local path by assigning `corePath`.
* In node environment, we use dynamic require and the default `corePath`
* is `$ffmpeg/core`.
*
* Typically the load() func might take few seconds to minutes to complete,
* better to do it as early as possible.
*
*/
const load = async () => { const load = async () => {
if (Module === null) {
log('info', 'load ffmpeg-core'); log('info', 'load ffmpeg-core');
Module = await getModule(options); if (Core === null) {
Module.setLogger((_log) => { log('info', 'loading ffmpeg-core');
detectCompletion(_log); const createFFmpegCore = await getCreateFFmpegCore(options);
parseProgress(_log, progress); Core = await createFFmpegCore({
logger(_log); printErr: (message) => parseMessage({ type: 'fferr', message }),
log(_log.type, _log.message); print: (message) => parseMessage({ type: 'ffout', message }),
}); });
if (ffmpeg === null) { ffmpeg = Core.cwrap('proxy_main', 'number', ['number', 'number']);
ffmpeg = Module.cwrap('proxy_main', 'number', ['number', 'number']);
}
log('info', 'ffmpeg-core loaded'); log('info', 'ffmpeg-core loaded');
}
};
const FS = (method, args) => {
if (Module === null) {
throw NO_LOAD;
} else { } else {
log('info', `FS.${method} ${args[0]}`); throw Error('ffmpeg.wasm was loaded, you should not load it again, use ffmpeg.isLoaded() to check next time.');
return Module.FS[method](...args);
} }
}; };
const write = async (path, data) => (
FS('writeFile', [path, await fetchFile(data)])
);
const writeText = (path, text) => ( /*
FS('writeFile', [path, text]) * Determine whether the Core is loaded.
); */
const isLoaded = () => Core !== null;
const read = (path) => ( /*
FS('readFile', [path]) * Run ffmpeg command.
); * This is the major function in ffmpeg.wasm, you can just imagine it
* as ffmpeg native cli and what you need to pass is the same.
const remove = (path) => ( *
FS('unlink', [path]) * For example, you can convert native command below:
); *
* ```
const ls = (path) => ( * $ ffmpeg -i video.avi -c:v libx264 video.mp4
FS('readdir', [path]) * ```
); *
* To
const run = (_args) => { *
if (ffmpeg === null) { * ```
* await ffmpeg.run('-i', 'video.avi', '-c:v', 'libx264', 'video.mp4');
* ```
*
*/
const run = (..._args) => {
log('info', `run ffmpeg command: ${_args.join(' ')}`);
if (Core === null) {
throw NO_LOAD; throw NO_LOAD;
} else if (running) { } else if (running) {
throw NO_MULTIPLE_RUN; throw Error('ffmpeg.wasm can only run one command at a time');
} else { } else {
running = true; running = true;
return new Promise((resolve) => { return new Promise((resolve) => {
const args = [...defaultArgs, ...parseArgs(_args)].filter((s) => s.length !== 0); const args = [...defaultArgs, ..._args].filter((s) => s.length !== 0);
log('info', `ffmpeg command: ${args.join(' ')}`);
runResolve = resolve; runResolve = resolve;
ffmpeg(args.length, stringList2pointer(Module, args)); ffmpeg(...parseArgs(Core, args));
}); });
} }
}; };
const transcode = (input, output, opts = '') => ( /*
run(`-i ${input} ${opts} ${output}`) * Run FS operations.
); * For input/output file of ffmpeg.wasm, it is required to save them to MEMFS
* first so that ffmpeg.wasm is able to consume them. Here we rely on the FS
const trim = (input, output, from, to, opts = '') => ( * methods provided by Emscripten.
run(`-i ${input} -ss ${from} -to ${to} ${opts} ${output}`) *
); * Common methods to use are:
* ffmpeg.FS('writeFile', 'video.avi', new Uint8Array(...)): writeFile writes
const concatDemuxer = (input, output, opts = '') => { * data to MEMFS. You need to use Uint8Array for binary data.
const text = input.reduce((acc, path) => `${acc}\nfile ${path}`, ''); * ffmpeg.FS('readFile', 'video.mp4'): readFile from MEMFS.
writeText('concat_list.txt', text); * ffmpeg.FS('unlink', 'video.map'): delete file from MEMFS.
return run(`-f concat -safe 0 -i concat_list.txt ${opts} ${output}`); *
* For more info, check https://emscripten.org/docs/api_reference/Filesystem-API.html
*
*/
const FS = (method, ...args) => {
log('info', `run FS.${method} ${args.map((arg) => (typeof arg === 'string' ? arg : `<${arg.length} bytes binary file>`)).join(' ')}`);
if (Core === null) {
throw NO_LOAD;
} else {
let ret = null;
try {
ret = Core.FS[method](...args);
} catch (e) {
if (method === 'readdir') {
throw Error(`ffmpeg.FS('readdir', '${args[0]}') error. Check if the path exists, ex: ffmpeg.FS('readdir', '/')`);
} else if (method === 'readFile') {
throw Error(`ffmpeg.FS('readFile', '${args[0]}') error. Check if the path exists`);
} else {
throw Error('Oops, something went wrong in FS operation.');
}
}
return ret;
}
}; };
setLogging(logging);
setCustomLogger(logger);
return { return {
load, load,
FS, isLoaded,
write,
writeText,
read,
remove,
ls,
run, run,
transcode, FS,
trim,
concatDemuxer,
}; };
}; };

View File

@ -1,6 +1,36 @@
require('regenerator-runtime/runtime'); require('regenerator-runtime/runtime');
const createFFmpeg = require('./createFFmpeg'); const createFFmpeg = require('./createFFmpeg');
const { fetchFile } = require('./node');
module.exports = { module.exports = {
/*
* Create ffmpeg instance.
* Each ffmpeg instance owns an isolated MEMFS and works
* independently.
*
* For example:
*
* ```
* const ffmpeg = createFFmpeg({
* log: true,
* logger: () => {},
* progress: () => {},
* corePath: '',
* })
* ```
*
* For the usage of these four arguments, check config.js
*
*/
createFFmpeg, createFFmpeg,
/*
* Helper function for fetching files from various resource.
* Sometimes the video/audio file you want to process may located
* in a remote URL and somewhere in your local file system.
*
* This helper function helps you to fetch to file and return an
* Uint8Array variable for ffmpeg.wasm to consume.
*
*/
fetchFile,
}; };

View File

@ -1,8 +1,6 @@
const defaultOptions = require('../constants/defaultOptions');
/* /*
* Default options for node environment * Default options for node environment
*/ */
module.exports = { module.exports = {
...defaultOptions, corePath: '@ffmpeg/core',
}; };

View File

@ -10,14 +10,21 @@ module.exports = async (_data) => {
} }
if (typeof _data === 'string') { if (typeof _data === 'string') {
if (isURL(_data) || _data.startsWith('chrome-extension://') || _data.startsWith('file://')) { /* From remote URL/server */
if (isURL(_data)
|| _data.startsWith('moz-extension://')
|| _data.startsWith('chrome-extension://')
|| _data.startsWith('file://')) {
const res = await fetch(_data); const res = await fetch(_data);
data = await res.arrayBuffer(); data = await res.arrayBuffer();
/* From base64 format */
} else if (/data:_data\/([a-zA-Z]*);base64,([^"]*)/.test(_data)) { } else if (/data:_data\/([a-zA-Z]*);base64,([^"]*)/.test(_data)) {
data = Buffer.from(_data.split(',')[1], 'base64'); data = Buffer.from(_data.split(',')[1], 'base64');
/* From local file path */
} else { } else {
data = await util.promisify(fs.readFile)(_data); data = await util.promisify(fs.readFile)(_data);
} }
/* From Buffer */
} else if (Buffer.isBuffer(_data)) { } else if (Buffer.isBuffer(_data)) {
data = _data; data = _data;
} }

View File

@ -0,0 +1,7 @@
const { log } = require('../utils/log');
module.exports = ({ corePath }) => new Promise((resolve) => {
log('info', `fetch ffmpeg.wasm-core script from ${corePath}`);
// eslint-disable-next-line import/no-dynamic-require
resolve(require(corePath));
});

View File

@ -1,6 +0,0 @@
module.exports = () => new Promise((resolve) => {
const Module = require('@ffmpeg/core');
Module.onRuntimeInitialized = () => {
resolve(Module);
};
});

View File

@ -1,9 +1,9 @@
const defaultOptions = require('./defaultOptions'); const defaultOptions = require('./defaultOptions');
const getModule = require('./getModule'); const getCreateFFmpegCore = require('./getCreateFFmpegCore');
const fetchFile = require('./fetchFile'); const fetchFile = require('./fetchFile');
module.exports = { module.exports = {
defaultOptions, defaultOptions,
getModule, getCreateFFmpegCore,
fetchFile, fetchFile,
}; };

View File

@ -1,21 +0,0 @@
const isElectron = require('is-electron');
module.exports = (key) => {
const env = {};
if (isElectron()) {
env.type = 'electron';
} else if (typeof window === 'object') {
env.type = 'browser';
} else if (typeof importScripts === 'function') {
env.type = 'webworker';
} else if (typeof process === 'object' && typeof require === 'function') {
env.type = 'node';
}
if (typeof key === 'undefined') {
return env;
}
return env[key];
};

View File

@ -1,9 +1,24 @@
let logging = false; let logging = false;
let customLogger = () => {};
exports.logging = logging; const setLogging = (_logging) => {
exports.setLogging = (_logging) => {
logging = _logging; logging = _logging;
}; };
exports.log = (type, message) => (logging ? console.log(`[${type}] ${message}`) : null); const setCustomLogger = (logger) => {
customLogger = logger;
};
const log = (type, message) => {
customLogger({ type, message });
if (logging) {
console.log(`[${type}] ${message}`);
}
};
module.exports = {
logging,
setLogging,
setCustomLogger,
log,
};

View File

@ -1,51 +1,9 @@
module.exports = (cmd) => { module.exports = (Core, args) => {
const args = []; const argsPtr = Core._malloc(args.length * Uint32Array.BYTES_PER_ELEMENT);
let nextDelimiter = 0; args.forEach((s, idx) => {
let prevDelimiter = 0; const buf = Core._malloc(s.length + 1);
// eslint-disable-next-line no-cond-assign Core.writeAsciiToMemory(s, buf);
while ((nextDelimiter = cmd.indexOf(' ', prevDelimiter)) >= 0) { Core.setValue(argsPtr + (Uint32Array.BYTES_PER_ELEMENT * idx), buf, 'i32');
let arg = cmd.substring(prevDelimiter, nextDelimiter); });
let quoteIdx = arg.indexOf('\''); return [args.length, argsPtr];
let dblQuoteIdx = arg.indexOf('"');
if (quoteIdx === 0 || dblQuoteIdx === 0) {
/* The argument has a quote at the start i.e, 'id=0,streams=0 id=1,streams=1' */
const delimiter = arg[0];
const endDelimiter = cmd.indexOf(delimiter, prevDelimiter + 1);
if (endDelimiter < 0) {
throw new Error(`Bad command escape sequence ${delimiter} near ${nextDelimiter}`);
}
arg = cmd.substring(prevDelimiter + 1, endDelimiter);
prevDelimiter = endDelimiter + 2;
args.push(arg);
} else if (quoteIdx > 0 || dblQuoteIdx > 0) {
/* The argument has a quote in it, it must be ended correctly i,e. title='test' */
if (quoteIdx === -1) quoteIdx = Infinity;
if (dblQuoteIdx === -1) dblQuoteIdx = Infinity;
const delimiter = (quoteIdx < dblQuoteIdx) ? '\'' : '"';
const quoteOffset = Math.min(quoteIdx, dblQuoteIdx);
const endDelimiter = cmd.indexOf(delimiter, prevDelimiter + quoteOffset + 1);
if (endDelimiter < 0) {
throw new Error(`Bad command escape sequence ${delimiter} near ${nextDelimiter}`);
}
arg = cmd.substring(prevDelimiter, endDelimiter + 1);
prevDelimiter = endDelimiter + 2;
args.push(arg);
} else if (arg !== '') {
args.push(arg);
prevDelimiter = nextDelimiter + 1;
} else {
prevDelimiter = nextDelimiter + 1;
}
}
if (prevDelimiter !== cmd.length) {
args.push(cmd.substring(prevDelimiter));
}
return args;
}; };

View File

@ -5,7 +5,7 @@ const ts2sec = (ts) => {
return (parseFloat(h) * 60 * 60) + (parseFloat(m) * 60) + parseFloat(s); return (parseFloat(h) * 60 * 60) + (parseFloat(m) * 60) + parseFloat(s);
}; };
module.exports = ({ message }, progress) => { module.exports = (message, progress) => {
if (typeof message === 'string') { if (typeof message === 'string') {
if (message.startsWith(' Duration')) { if (message.startsWith(' Duration')) {
const ts = message.split(', ')[0].split(': ')[1]; const ts = message.split(', ')[0].split(': ')[1];
@ -19,6 +19,7 @@ module.exports = ({ message }, progress) => {
progress({ ratio: t / duration }); progress({ ratio: t / duration });
} else if (message.startsWith('video:')) { } else if (message.startsWith('video:')) {
progress({ ratio: 1 }); progress({ ratio: 1 });
duration = 0;
} }
} }
}; };

View File

@ -1,12 +0,0 @@
const isBrowser = require('./getEnvironment')('type') === 'browser';
const resolveURL = isBrowser ? require('resolve-url') : s => s; // eslint-disable-line
module.exports = (options) => {
const opts = { ...options };
['corePath'].forEach((key) => {
if (typeof options[key] !== 'undefined') {
opts[key] = resolveURL(opts[key]);
}
});
return opts;
};

View File

@ -1,8 +0,0 @@
module.exports = (Module, s) => {
const ptr = Module._malloc((s.length + 1) * Uint8Array.BYTES_PER_ELEMENT);
for (let i = 0; i < s.length; i += 1) {
Module.setValue(ptr + i, s.charCodeAt(i), 'i8');
}
Module.setValue(ptr + s.length, 0, 'i8');
return ptr;
};

View File

@ -1,12 +0,0 @@
const string2pointer = require('./string2pointer');
module.exports = (Module, strList) => {
const listPtr = Module._malloc(strList.length * Uint32Array.BYTES_PER_ELEMENT);
strList.forEach((s, idx) => {
const strPtr = string2pointer(Module, s);
Module.setValue(listPtr + (4 * idx), strPtr, 'i32');
});
return listPtr;
};