ffcreator
Version:
FFCreator is a lightweight and flexible short video production library
360 lines (314 loc) • 9.21 kB
JavaScript
'use strict';
/**
* Synthesis - A class for video synthesis.
* Mainly rely on the function of ffmpeg to synthesize video and audio.
*
* ####Example:
*
* const synthesis = new Synthesis(conf);
* synthesis.addStream(stream);
* synthesis.addAudios(audios);
* synthesis.start();
*
*
* @class
*/
const path = require('path');
const isEmpty = require('lodash/isEmpty');
const forEach = require('lodash/forEach');
const FS = require('../utils/fs');
const Utils = require('../utils/utils');
const FFLogger = require('../utils/logger');
const FFmpegUtil = require('../utils/ffmpeg');
const FFEventer = require('../event/eventer');
class Synthesis extends FFEventer {
constructor(conf) {
super();
this.conf = conf;
this.cover = null;
this.audios = null;
this.duration = 0;
this.inputCount = 0;
this.framesNum = 100;
this.outputOptions = [];
const threads = conf.getVal('threads');
this.command = FFmpegUtil.createCommand({ threads });
}
/**
* set total frames number
* @param {number} framesNum - total frames number
* @public
*/
setFramesNum(framesNum) {
this.framesNum = framesNum;
}
/**
* Set the ffcreator total duration
* @param {number} duration - total duration time
* @public
*/
setDuration(duration) {
this.duration = duration;
}
/**
* Add the intermediate pictures processed in the cache directory to ffmpeg input
* @param {Stream} stream - A readability stream
* @public
*/
addStream(stream) {
const { conf, command } = this;
const type = conf.getVal('cacheFormat');
command.addInput(stream);
if (type === 'raw') command.inputFormat('rawvideo');
this.addStreamInputOptions();
this.inputCount++;
}
/**
* Add input options for image input stream
* @private
*/
addStreamInputOptions() {
const { conf, command } = this;
const size = conf.getWH('x');
const fps = conf.getVal('fps');
const type = conf.getVal('cacheFormat');
const opts = ['-framerate', fps];
// raw (unencoded data in BGRA order on little-endian (most) systems, ARGB on big-endian systems; top-to-bottom)
const newOpts =
type === 'raw'
? ['-s', size, '-vcodec', 'rawvideo', '-pixel_format', 'bgra', '-video_size', size]
: [];
command.inputOptions(opts.concat(newOpts));
}
/**
* Add ordinary ffmpeg cover input elements
* @param {string} input - A input elements
* @public
*/
addCover(cover) {
this.cover = cover;
}
/**
* add audios to ffmpeg config
* @public
*/
addAudios(audios) {
if (isEmpty(audios)) return;
const { command } = this;
forEach(audios, audio => {
audio.addInput(command);
this.inputCount++;
});
this.audios = audios;
}
/**
* Add one or more background sounds
* ##Note: Usage of adelay/volume filter
* [1]adelay=5000|5000,volume=1[a];
* [2]adelay=10000|10000,volume=10[b];
* [3]adelay=15000|15000,volume=2[c];
* [a][b][c]amix=3[a]
* @param {array} audios - background sounds
* @private
*/
addAudioFilter() {
const { conf, audios, duration } = this;
if (isEmpty(audios)) return;
const normalizeAudio = conf.getVal('normalizeAudio');
const filters = [];
const length = audios.length;
let outputs = '';
forEach(audios, (audio, index) => {
const output = `audio${index}`;
const audioCommand = audio.toFilterCommand({ index, duration });
filters.push(audioCommand);
outputs += `[${output}]`;
});
if (normalizeAudio) {
filters.push(`${outputs}amix=inputs=${length}:normalize=0`);
} else {
filters.push(`${outputs}amix=${length}`);
}
this.command.complexFilter(filters);
}
/**
* Add ffmpeg input configuration
* @private
*/
addInputOptions() {
const { conf, command } = this;
const customOpts = conf.getVal('inputOptions');
customOpts && command.inputOptions(customOpts);
}
/**
* Get default ffmpeg output configuration
* @private
*/
getDefaultOutputOptions(configs) {
const { merge, options = [] } = configs || {};
const { conf } = this;
const vb = conf.getVal('vb');
const fps = conf.getVal('fps');
const crf = conf.getVal('crf');
const preset = conf.getVal('preset');
let opts = []
// misc
.concat([
'-hide_banner', // hide_banner - parameter, you can display only meta information
'-map_metadata',
'-1',
'-map_chapters',
'-1',
])
// video
.concat([
'-c:v',
'libx264', // c:v - H.264
'-profile:v',
'main', // profile:v - main profile: mainstream image quality. Provide I / P / B frames
'-preset',
preset, // preset - compromised encoding speed
'-crf',
crf, // crf - The range of quantization ratio is 0 ~ 51, where 0 is lossless mode, 23 is the default value, 51 may be the worst
'-movflags',
'faststart',
'-pix_fmt',
'yuv420p',
'-r',
fps,
]);
//---- vb -----
if (vb) {
opts = opts.concat(['-vb', vb]);
}
if (merge) {
opts = opts.concat(options);
} else {
opts = options;
}
return opts;
}
/**
* Add ffmpeg output configuration
* @private
*/
addOutputOptions() {
const { conf, audios, duration } = this;
const defaultOutputOptions = conf.getVal('defaultOutputOptions');
// custom
const customOpts = conf.getVal('outputOptions');
if (customOpts) FFmpegUtil.concatOpts(this.outputOptions, customOpts);
// default
const defaultOpts = this.getDefaultOutputOptions(defaultOutputOptions);
if (defaultOutputOptions) FFmpegUtil.concatOpts(this.outputOptions, defaultOpts);
// audios
if (!isEmpty(audios)) {
FFmpegUtil.concatOpts(this.outputOptions, ['-c:a', 'aac']);
}
this.command.outputOptions(this.outputOptions);
// set max duration
this.command.setDuration(duration);
}
/**
* Set ffmpeg input path
* @private
*/
addOutput() {
const finalOutput = this.getOutputPath('final');
const currOutput = this.getOutputPath('curr');
FS.ensureDir(finalOutput, true);
FS.ensureDir(currOutput, true);
this.command.output(currOutput);
}
/**
* Open ffmpeg production and processing
* @public
*/
start() {
this.addInputOptions();
this.addAudioFilter();
this.addOutputOptions();
this.addCommandEvents();
this.addOutput();
this.command.run();
}
/**
* Get the final output file path
* @private
*/
getOutputPath(real = null) {
const { conf, cover } = this;
let output = conf.getVal('output');
if (cover && real !== 'final') {
const pathId = conf.getVal('pathId');
const dir = conf.getVal('detailedCacheDir');
output = path.join(dir, pathId + '_cover.mp4');
}
return path.normalize(output);
}
/**
* Add FFmpeg event to command
* @private
*/
addCommandEvents() {
const { conf, cover, command, framesNum } = this;
const ffmpeglog = conf.getVal('ffmpeglog');
const highWaterMark = conf.getVal('highWaterMark');
const video = this.getOutputPath('curr');
const output = this.getOutputPath('final');
// complete and error callback
const completeFunc = () => {
this.emit({ type: 'synthesis-complete', output });
FFLogger.info({ pos: 'Synthesis', msg: 'synthesis complete.' });
};
const errorFunc = (error, stdout, stderr) => {
this.emitError({ error, pos: 'Synthesis' });
FFLogger.error({
error,
pos: 'Synthesis',
msg: `stdout:${stdout} \n stderr:${stderr}`,
});
};
// start event
command.on('start', commandLine => {
FFmpegUtil.setHighWaterMark({ command, highWaterMark });
this.emit({ type: 'synthesis-start', command: commandLine });
if (ffmpeglog) {
command.ffmpegProc.stderr.on('data', data => console.log(data));
}
// log info
FFLogger.info({ pos: 'Synthesis', msg: `synthesis start: ${commandLine}` });
});
// progress event
command.on('progress', progress => {
let percent = Utils.floor(progress.frames / framesNum, 2);
percent = Math.min(1, percent);
this.emitProgress({ percent });
// log info
const percent100 = Math.floor(percent * 100);
FFLogger.info({ pos: 'Synthesis', msg: `synthesis progress: ${percent100}% done.` });
});
command.on('end', async () => {
// check if cover is included
if (cover) {
FFmpegUtil.addCoverImage({ video, output, cover })
.then(completeFunc)
.catch(([error, stdout, stderr]) => errorFunc(error, stdout, stderr));
} else {
completeFunc();
}
});
// error
command.on('error', (error, stdout, stderr) => errorFunc(error, stdout, stderr));
}
destroy() {
super.destroy();
FFmpegUtil.destroy(this.command);
this.conf = null;
this.cover = null;
this.audios = null;
this.command = null;
this.outputOptions.length = 0;
}
}
module.exports = Synthesis;