dizquetv/src/ffmpeg.js
McCloudS 3431f06655
Update ffmpeg.js
missing quote after show name for FFMPEG
2020-08-13 12:39:18 -05:00

401 lines
18 KiB
JavaScript

const spawn = require('child_process').spawn
const events = require('events')
//they can customize this by modifying the picture in .dizquetv folder
const MAXIMUM_ERROR_DURATION_MS = 60000;
class FFMPEG extends events.EventEmitter {
constructor(opts, channel) {
super()
this.opts = opts;
this.errorPicturePath = `http://localhost:${process.env.PORT}/images/generic-error-screen.png`;
if (! this.opts.enableFFMPEGTranscoding) {
//this ensures transcoding is completely disabled even if
// some settings are true
this.opts.normalizeAudio = false;
this.opts.normalizeAudioCodec = false;
this.opts.normalizeVideoCodec = false;
this.opts.errorScreen = 'kill';
this.opts.normalizeResolution = false;
this.opts.audioVolumePercent = 100;
}
this.channel = channel
this.ffmpegPath = opts.ffmpegPath
var parsed = parseResolutionString(opts.targetResolution);
this.wantedW = parsed.w;
this.wantedH = parsed.h;
this.sentData = false;
this.apad = this.opts.normalizeAudio;
this.audioChannelsSampleRate = this.opts.normalizeAudio;
this.ensureResolution = this.opts.normalizeResolution;
this.volumePercent = this.opts.audioVolumePercent;
}
async spawnConcat(streamUrl) {
this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true)
}
async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type) {
this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false);
}
async spawnError(title, subtitle, duration) {
if (! this.opts.enableFFMPEGTranscoding || this.opts.errorScreen == 'kill') {
console.error("error: " + title + " ; " + subtitle);
this.emit('error', { code: -1, cmd: `error stream disabled. ${title} ${subtitle}`} )
return;
}
if (typeof(duration) === 'undefined') {
//set a place-holder duration
console.log("No duration found for error stream, using placeholder");
duration = MAXIMUM_ERROR_DURATION_MS ;
}
duration = Math.min(MAXIMUM_ERROR_DURATION_MS, duration);
let streamStats = {
videoWidth : this.wantedW,
videoHeight : this.wantedH,
duration : duration,
};
this.spawn({ errorTitle: title , subtitle: subtitle }, streamStats, undefined, `${streamStats.duration}ms`, true, false, 'error', false)
}
async spawnOffline(duration) {
if (! this.opts.enableFFMPEGTranscoding) {
console.log("The channel has an offline period scheduled for this time slot. FFMPEG transcoding is disabled, so it is not possible to render an offline screen. Ending the stream instead");
this.emit('end', { code: -1, cmd: `offline stream disabled.`} )
return;
}
let streamStats = {
videoWidth : this.wantedW,
videoHeight : this.wantedH,
duration : duration,
};
this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false);
}
async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) {
let ffmpegArgs = [
`-threads`, this.opts.threads,
`-fflags`, `+genpts+discardcorrupt+igndts`];
if (limitRead === true)
ffmpegArgs.push(`-re`)
if (typeof startTime !== 'undefined')
ffmpegArgs.push(`-ss`, startTime)
if (isConcatPlaylist == true)
ffmpegArgs.push(`-f`, `concat`,
`-safe`, `0`,
`-protocol_whitelist`, `file,http,tcp,https,tcp,tls`)
// Map correct audio index. '?' so doesn't fail if no stream available.
let audioIndex = (typeof streamStats === 'undefined') ? 'a' : `${streamStats.audioIndex}`;
//TODO: Do something about missing audio stream
if (!isConcatPlaylist) {
// When we have an individual stream, there is a pipeline of possible
// filters to apply.
//
var doOverlay = enableIcon;
var iW = streamStats.videoWidth;
var iH = streamStats.videoHeight;
// (explanation is the same for the video and audio streams)
// The initial stream is called '[video]'
var currentVideo = "[video]";
var currentAudio = "[audio]";
// Initially, videoComplex does nothing besides assigning the label
// to the input stream
var videoIndex = 'v';
var audioComplex = `;[0:${audioIndex}]anull[audio]`;
var videoComplex = `;[0:${videoIndex}]null[video]`;
// Depending on the options we will apply multiple filters
// each filter modifies the current video stream. Adds a filter to
// the videoComplex variable. The result of the filter becomes the
// new currentVideo value.
//
// When adding filters, make sure that
// videoComplex always begins wiht ; and doesn't end with ;
// prepare input streams
if ( typeof(streamUrl.errorTitle) !== 'undefined') {
doOverlay = false; //never show icon in the error screen
// for error stream, we have to generate the input as well
this.apad = false; //all of these generate audio correctly-aligned to video so there is no need for apad
this.audioChannelsSampleRate = true; //we'll need these
if (this.ensureResolution) {
//all of the error strings already choose the resolution to
//match iW x iH , so with this we save ourselves a second
// scale filter
iW = this.wantedW;
iH = this.wantedH;
}
ffmpegArgs.push("-r" , "24");
if ( streamUrl.errorTitle == 'offline' ) {
ffmpegArgs.push(
'-loop', '1',
'-i', `${this.channel.offlinePicture}`,
);
videoComplex = `;[0:0]loop=loop=-1:size=1:start=0[looped];[looped]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`;
} else if (this.opts.errorScreen == 'static') {
ffmpegArgs.push(
'-f', 'lavfi',
'-i', `nullsrc=s=64x36`);
videoComplex = `;geq=random(1)*255:128:128[videoz];[videoz]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`;
} else if (this.opts.errorScreen == 'testsrc') {
ffmpegArgs.push(
'-f', 'lavfi',
'-i', `testsrc=size=${iW}x${iH}`,
);
videoComplex = `;realtime[videox]`;
} else if (this.opts.errorScreen == 'text') {
var sz2 = Math.ceil( (iH) / 33.0);
var sz1 = Math.ceil( sz2 * 3. / 2. );
var sz3 = 2*sz2;
ffmpegArgs.push(
'-f', 'lavfi',
'-i', `color=c=black:s=${iW}x${iH}`
);
videoComplex = `;drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=${sz1}:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2:text='${streamUrl.errorTitle}',drawtext=fontfile=${process.env.DATABASE}/font.ttf:fontsize=${sz2}:fontcolor=white:x=(w-text_w)/2:y=(h+text_h+${sz3})/2:text='${streamUrl.subtitle}'[videoy];[videoy]realtime[videox]`;
} else if (this.opts.errorScreen == 'blank') {
ffmpegArgs.push(
'-f', 'lavfi',
'-i', `color=c=black:s=${iW}x${iH}`
);
videoComplex = `;realtime[videox]`;
} else {//'pic'
ffmpegArgs.push(
'-loop', '1',
'-i', `${this.errorPicturePath}`,
);
videoComplex = `;[0:0]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`;
}
let durstr = `duration=${streamStats.duration}ms`;
//silent
audioComplex = `;aevalsrc=0:${durstr}[audioy]`;
if ( streamUrl.errorTitle == 'offline' ) {
if (
(typeof(this.channel.offlineSoundtrack) !== 'undefined')
&& (this.channel.offlineSoundtrack != '' )
) {
ffmpegArgs.push('-i', `${this.channel.offlineSoundtrack}`);
// I don't really understand why, but you need to use this
// 'size' in order to make the soundtrack actually loop
audioComplex = `;[1:a]aloop=loop=-1:size=2147483647[audioy]`;
}
} else if (this.opts.errorAudio == 'whitenoise') {
audioComplex = `;aevalsrc=-2+0.1*random(0):${durstr}[audioy]`;
} else if (this.opts.errorAudio == 'sine') {
audioComplex = `;sine=f=440:${durstr}[audiox];[audiox]volume=-35dB[audioy]`;
}
ffmpegArgs.push('-pix_fmt' , 'yuv420p' );
audioComplex += ';[audioy]arealtime[audiox]';
currentVideo = "[videox]";
currentAudio = "[audiox]";
} else {
ffmpegArgs.push(`-i`, streamUrl);
}
if (doOverlay) {
ffmpegArgs.push(`-i`, `${this.channel.icon}` );
}
// Resolution fix: Add scale filter, current stream becomes [siz]
if (this.ensureResolution && (iW != this.wantedW || iH != this.wantedH) ) {
//Maybe the scaling algorithm could be configurable. bicubic seems good though
videoComplex += `;${currentVideo}scale=${this.wantedW}:${this.wantedH}:flags=bicubic:force_original_aspect_ratio=decrease,pad=${this.wantedW}:${this.wantedH}:(ow-iw)/2:(oh-ih)/2[siz]`
currentVideo = "[siz]";
iW = this.wantedW;
iH = this.wantedH;
}
// Channel overlay:
if (doOverlay) {
if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
let posAry = [ '20:20', 'W-w-20:20', '20:H-h-20', 'W-w-20:H-h-20'] // top-left, top-right, bottom-left, bottom-right (with 20px padding)
let icnDur = ''
if (this.channel.iconDuration > 0)
icnDur = `:enable='between(t,0,${this.channel.iconDuration})'`
videoComplex += `;[1:v]scale=${this.channel.iconWidth}:-1[icn];${currentVideo}[icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[comb]`
currentVideo = '[comb]';
}
if (this.volumePercent != 100) {
var f = this.volumePercent / 100.0;
audioComplex += `;${currentAudio}volume=${f}[boosted]`;
currentAudio = '[boosted]';
}
// Align audio is just the apad filter applied to audio stream
if (this.apad) {
audioComplex += `;${currentAudio}apad=whole_dur=${streamStats.duration}ms[padded]`;
currentAudio = '[padded]';
} else if (this.audioChannelsSampleRate) {
//TODO: Do not set this to true if audio channels and sample rate are already good
transcodeAudio = true;
}
// If no filters have been applied, then the stream will still be
// [video] , in that case, we do not actually add the video stuff to
// filter_complex and this allows us to avoid transcoding.
var transcodeVideo = (this.opts.normalizeVideoCodec && isDifferentVideoCodec( streamStats.videoCodec, this.opts.videoEncoder) );
var transcodeAudio = (this.opts.normalizeAudioCodec && isDifferentAudioCodec( streamStats.audioCodec, this.opts.audioEncoder) );
var filterComplex = '';
if (currentVideo != '[video]') {
transcodeVideo = true; //this is useful so that it adds some lines below
filterComplex += videoComplex;
} else {
currentVideo = `0:${videoIndex}`;
}
// same with audio:
if (currentAudio != '[audio]') {
transcodeAudio = true;
filterComplex += audioComplex;
} else {
currentAudio = `0:${audioIndex}`;
}
//If there is a filter complex, add it.
if (filterComplex != '') {
ffmpegArgs.push(`-filter_complex` , filterComplex.slice(1) );
if (this.alignAudio) {
ffmpegArgs.push('-shortest');
}
}
ffmpegArgs.push(
'-map', currentVideo,
'-map', currentAudio,
`-c:v`, (transcodeVideo ? this.opts.videoEncoder : 'copy'),
`-flags`, `cgop+ilme`,
`-sc_threshold`, `1000000000`
);
if ( transcodeVideo ) {
// add the video encoder flags
ffmpegArgs.push(
`-b:v`, `${this.opts.videoBitrate}k`,
`-minrate:v`, `${this.opts.videoBitrate}k`,
`-maxrate:v`, `${this.opts.videoBitrate}k`,
`-bufsize:v`, `${this.opts.videoBufSize}k`
);
}
if ( transcodeAudio ) {
// add the audio encoder flags
ffmpegArgs.push(
`-b:a`, `${this.opts.audioBitrate}k`,
`-minrate:a`, `${this.opts.audioBitrate}k`,
`-maxrate:a`, `${this.opts.audioBitrate}k`,
`-bufsize:a`, `${this.opts.videoBufSize}k`
);
if (this.audioChannelsSampleRate) {
ffmpegArgs.push(
`-ac`, `${this.opts.audioChannels}`,
`-ar`, `${this.opts.audioSampleRate}k`
);
}
}
ffmpegArgs.push(
`-c:a`, (transcodeAudio ? this.opts.audioEncoder : 'copy'),
'-map_metadata', '-1',
'-movflags', '+faststart',
`-muxdelay`, `0`,
`-muxpreload`, `0`
);
} else {
//Concat stream is simpler and should always copy the codec
ffmpegArgs.push(
`-probesize`, `100000000`,
`-i`, streamUrl,
`-map`, `0:v`,
`-map`, `0:${audioIndex}`,
`-c`, `copy`,
`-muxdelay`, this.opts.concatMuxDelay,
`-muxpreload`, this.opts.concatMuxDelay);
}
ffmpegArgs.push(`-metadata`,
`service_provider="dizqueTV"`,
`-metadata`,
`service_name="${this.channel.name}"`,
`-f`, `mpegts`);
//t should be before output
if (typeof duration !== 'undefined') {
ffmpegArgs.push(`-t`, duration)
}
ffmpegArgs.push(`pipe:1`)
let doLogs = this.opts.logFfmpeg && !isConcatPlaylist;
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } );
this.ffmpeg.stdout.on('data', (chunk) => {
this.sentData = true;
this.emit('data', chunk)
})
this.ffmpeg.on('close', (code) => {
if (code === null) {
this.emit('close', code)
} else if (code === 0) {
this.emit('end')
} else if (code === 255) {
if (! this.sentData) {
this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` })
}
this.emit('close', code)
} else {
this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` })
}
})
}
kill() {
if (typeof this.ffmpeg != "undefined") {
this.ffmpeg.kill()
}
}
}
function isDifferentVideoCodec(codec, encoder) {
if (codec == 'mpeg2video') {
return ! encoder.includes("mpeg2");
} else if (codec == 'h264') {
return ! encoder.includes("264");
} else if (codec == 'hevc') {
return !( encoder.includes("265") || encoder.includes("hevc") );
}
// if the encoder/codec combinations are unknown, always encode, just in case
return true;
}
function isDifferentAudioCodec(codec, encoder) {
if (codec == 'mp3') {
return !( encoder.includes("mp3") || encoder.includes("lame") );
} else if (codec == 'aac') {
return !encoder.includes("aac");
} else if (codec == 'ac3') {
return !encoder.includes("ac3");
} else if (codec == 'flac') {
return !encoder.includes("flac");
}
// if the encoder/codec combinations are unknown, always encode, just in case
return true;
}
function parseResolutionString(s) {
var i = s.indexOf('x');
if (i == -1) {
return {w:1920, h:1080}
}
return {
w: parseInt( s.substring(0,i) , 10 ),
h: parseInt( s.substring(i+1) , 10 ),
}
}
module.exports = FFMPEG