const spawn = require('child_process').spawn const events = require('events') const MAXIMUM_ERROR_DURATION_MS = 60000; const REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE = 120; class FFMPEG extends events.EventEmitter { constructor(opts, channel) { super() this.opts = opts; this.errorPicturePath = `http://localhost:${process.env.PORT}/images/generic-error-screen.png`; this.ffmpegName = "unnamed ffmpeg"; 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.opts.maxFPS = REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE; } this.channel = channel this.ffmpegPath = opts.ffmpegPath let resString = opts.targetResolution; if ( (typeof(channel.transcoding) !== 'undefined') && (channel.transcoding.targetResolution != null) && (typeof(channel.transcoding.targetResolution) != 'undefined') && (channel.transcoding.targetResolution != "") ) { resString = channel.transcoding.targetResolution; } if ( (typeof(channel.transcoding) !== 'undefined') && (channel.transcoding.videoBitrate != null) && (typeof(channel.transcoding.videoBitrate) != 'undefined') && (channel.transcoding.videoBitrate != 0) ) { opts.videoBitrate = channel.transcoding.videoBitrate; } if ( (typeof(channel.transcoding) !== 'undefined') && (channel.transcoding.videoBufSize != null) && (typeof(channel.transcoding.videoBufSize) != 'undefined') && (channel.transcoding.videoBufSize != 0) ) { opts.videoBufSize = channel.transcoding.videoBufSize; } let parsed = parseResolutionString(resString); 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; this.hasBeenKilled = false; this.audioOnly = false; } setAudioOnly(audioOnly) { this.audioOnly = audioOnly; } async spawnConcat(streamUrl) { return await this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true) } async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type) { return await 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, }; return await 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, }; return await this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false); } async spawn(streamUrl, streamStats, startTime, duration, limitRead, watermark, type, isConcatPlaylist) { let ffmpegArgs = [ `-threads`, isConcatPlaylist? 1 : this.opts.threads, `-fflags`, `+genpts+discardcorrupt+igndts`]; let stillImage = false; if ( (limitRead === true) && ( (this.audioOnly !== true) || ( typeof(streamUrl.errorTitle) === 'undefined') ) ) { 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) { let inputFiles = 0; let audioFile = -1; let videoFile = -1; let overlayFile = -1; if ( typeof(streamUrl.errorTitle) === 'undefined') { ffmpegArgs.push(`-i`, streamUrl); videoFile = inputFiles++; audioFile = videoFile; } // When we have an individual stream, there is a pipeline of possible // filters to apply. // var doOverlay = ( (typeof(watermark)==='undefined') || (watermark != null) ); 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 = `;[${audioFile}:${audioIndex}]anull[audio]`; var videoComplex = `;[${videoFile}:${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 ; if ( streamStats.videoFramerate >= this.opts.maxFPS + 0.000001 ) { videoComplex += `;${currentVideo}fps=${this.opts.maxFPS}[fpchange]`; currentVideo ="[fpchange]"; } // deinterlace if desired if (streamStats.videoScanType == 'interlaced' && this.opts.deinterlaceFilter != 'none') { videoComplex += `;${currentVideo}${this.opts.deinterlaceFilter}[deinterlaced]`; currentVideo = "[deinterlaced]"; } // prepare input streams if ( ( typeof(streamUrl.errorTitle) !== 'undefined') || (streamStats.audioOnly) ) { 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 //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; let durstr = `duration=${streamStats.duration}ms`; if (this.audioOnly !== true) { let pic = null; //does an image to play exist? if ( (typeof(streamUrl.errorTitle) === 'undefined') && (streamStats.audioOnly) ) { pic = streamStats.placeholderImage; } else if ( streamUrl.errorTitle == 'offline') { pic = `${this.channel.offlinePicture}`; } else if ( this.opts.errorScreen == 'pic' ) { pic = `${this.errorPicturePath}`; } if (pic != null) { if (this.opts.noRealTime === true) { ffmpegArgs.push("-r" , "60"); } else { ffmpegArgs.push("-r" , "24"); } ffmpegArgs.push( '-i', pic, ); if ( (typeof duration === 'undefined') && (typeof(streamStats.duration) !== 'undefined' ) ) { //add 150 milliseconds just in case, exact duration seems to cut out the last bits of music some times. duration = `${streamStats.duration + 150}ms`; } videoComplex = `;[${inputFiles++}:0]format=yuv420p[formatted]`; videoComplex +=`;[formatted]scale=w=${iW}:h=${iH}:force_original_aspect_ratio=1[scaled]`; videoComplex += `;[scaled]pad=${iW}:${iH}:(ow-iw)/2:(oh-ih)/2[padded]`; videoComplex += `;[padded]loop=loop=-1:size=1:start=0`; if (this.opts.noRealTime !== true) { videoComplex +=`[looped];[looped]realtime[videox]`; } else { videoComplex +=`[videox]` } //this tune apparently makes the video compress better // when it is the same image stillImage = true; this.volumePercent = Math.min(70, this.volumePercent); } 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]`; inputFiles++; } else if (this.opts.errorScreen == 'testsrc') { ffmpegArgs.push( '-f', 'lavfi', '-i', `testsrc=size=${iW}x${iH}`, ); videoComplex = `;realtime[videox]`; inputFiles++; } 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}` ); inputFiles++; 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 { //blank ffmpegArgs.push( '-f', 'lavfi', '-i', `color=c=black:s=${iW}x${iH}` ); inputFiles++; videoComplex = `;realtime[videox]`; } } if (typeof(streamUrl.errorTitle) !== 'undefined') { //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 = `;[${inputFiles++}:a]aloop=loop=-1:size=2147483647[audioy]`; } } else if ( (this.opts.errorAudio == 'whitenoise') || ( !(this.opts.errorAudio == 'sine') && (this.audioOnly === true) //when it's in audio-only mode, silent stream is confusing for errors. ) ) { audioComplex = `;aevalsrc=random(0):${durstr}[audioy]`; this.volumePercent = Math.min(70, this.volumePercent); } else if (this.opts.errorAudio == 'sine') { audioComplex = `;sine=f=440:${durstr}[audioy]`; this.volumePercent = Math.min(70, this.volumePercent); } if ( this.audioOnly !== true ) { ffmpegArgs.push('-pix_fmt' , 'yuv420p' ); } audioComplex += ';[audioy]arealtime[audiox]'; currentAudio = "[audiox]"; } currentVideo = "[videox]"; } if (doOverlay) { if (watermark.animated === true) { ffmpegArgs.push('-ignore_loop', '0'); } ffmpegArgs.push(`-i`, `${watermark.url}` ); overlayFile = inputFiles++; this.ensureResolution = true; } // Resolution fix: Add scale filter, current stream becomes [siz] let beforeSizeChange = currentVideo; let algo = this.opts.scalingAlgorithm; let resizeMsg = ""; if ( (!streamStats.audioOnly) && ( (this.ensureResolution && ( streamStats.anamorphic || (iW != this.wantedW || iH != this.wantedH) ) ) || isLargerResolution(iW, iH, this.wantedW, this.wantedH) ) ) { //scaler stuff, need to change the size of the video and also add bars // calculate wanted aspect ratio let p = iW * streamStats.pixelP ; let q = iH * streamStats.pixelQ; let g = gcd(q,p); // and people kept telling me programming contests knowledge had no use real programming! p = Math.floor(p / g); q = Math.floor(q / g); let hypotheticalW1 = this.wantedW; let hypotheticalH1 = Math.floor(hypotheticalW1*q / p); let hypotheticalH2 = this.wantedH; let hypotheticalW2 = Math.floor( (this.wantedH * p) / q ); let cw, ch; if (hypotheticalH1 <= this.wantedH) { cw = hypotheticalW1; ch = hypotheticalH1; } else { cw = hypotheticalW2; ch = hypotheticalH2; } videoComplex += `;${currentVideo}scale=${cw}:${ch}:flags=${algo}[scaled]`; currentVideo = "scaled"; resizeMsg = `Stretch to ${cw} x ${ch}. To fit target resolution of ${this.wantedW} x ${this.wantedH}.`; if (this.ensureResolution) { console.log(`First stretch to ${cw} x ${ch}. Then add padding to make it ${this.wantedW} x ${this.wantedH} `); } else if (cw % 2 == 1 || ch % 2 ==1) { //we need to add padding so that the video dimensions are even let xw = cw + cw % 2; let xh = ch + ch % 2; resizeMsg = `Stretch to ${cw} x ${ch}. To fit target resolution of ${this.wantedW} x ${this.wantedH}. Then add 1 pixel of padding so that dimensions are not odd numbers, because they are frowned upon. The final resolution will be ${xw} x ${xh}`; this.wantedW = xw; this.wantedH = xh; } else { resizeMsg = `Stretch to ${cw} x ${ch}. To fit target resolution of ${this.wantedW} x ${this.wantedH}.`; } if ( (this.wantedW != cw) || (this.wantedH != ch) ) { // also add black bars, because in this case it HAS to be this resolution videoComplex += `;[${currentVideo}]pad=${this.wantedW}:${this.wantedH}:(ow-iw)/2:(oh-ih)/2[blackpadded]`; currentVideo = "blackpadded"; } let name = "siz"; if (! this.ensureResolution && (beforeSizeChange != '[fpchange]') ) { name = "minsiz"; } videoComplex += `;[${currentVideo}]setsar=1[${name}]`; currentVideo = `[${name}]`; iW = this.wantedW; iH = this.wantedH; } // Channel watermark: if (doOverlay && (this.audioOnly !== true) ) { var pW =watermark.width; var w = Math.round( pW * iW / 100.0 ); var mpHorz = watermark.horizontalMargin; var mpVert = watermark.verticalMargin; var horz = Math.round( mpHorz * iW / 100.0 ); var vert = Math.round( mpVert * iH / 100.0 ); let posAry = { 'top-left': `x=${horz}:y=${vert}`, 'top-right': `x=W-w-${horz}:y=${vert}`, 'bottom-left': `x=${horz}:y=H-h-${vert}`, 'bottom-right': `x=W-w-${horz}:y=H-h-${vert}`, } let icnDur = '' if (watermark.duration > 0) { icnDur = `:enable='between(t,0,${watermark.duration})'` } let waterVideo = `[${overlayFile}:v]`; if ( ! watermark.fixedSize) { videoComplex += `;${waterVideo}scale=${w}:-1[icn]`; waterVideo = '[icn]'; } let p = posAry[watermark.position]; if (typeof(p) === 'undefined') { throw Error("Invalid watermark position: " + watermark.position); } let overlayShortest = ""; if (watermark.animated) { overlayShortest = "shortest=1:"; } videoComplex += `;${currentVideo}${waterVideo}overlay=${overlayShortest}${p}${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 && (this.audioOnly !== true) ) { //it doesn't make much sense to pad audio when there is no video 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 ( (!transcodeVideo) && (currentVideo == '[minsiz]') ) { //do not change resolution if no other transcoding will be done // and resolution normalization is off currentVideo = beforeSizeChange; } else { console.log(resizeMsg) } if (this.audioOnly !== true) { if (currentVideo != '[video]') { transcodeVideo = true; //this is useful so that it adds some lines below filterComplex += videoComplex; } else { currentVideo = `${videoFile}:${videoIndex}`; } } // same with audio: if (currentAudio != '[audio]') { transcodeAudio = true; filterComplex += audioComplex; } else { currentAudio = `${audioFile}:${audioIndex}`; } //If there is a filter complex, add it. if (filterComplex != '') { ffmpegArgs.push(`-filter_complex` , filterComplex.slice(1) ); if (this.alignAudio) { ffmpegArgs.push('-shortest'); } } if (this.audioOnly !== true) { ffmpegArgs.push( '-map', currentVideo, `-c:v`, (transcodeVideo ? this.opts.videoEncoder : 'copy'), `-sc_threshold`, `1000000000`, ); // do not use -tune stillimage for nv if (stillImage && ! this.opts.videoEncoder.toLowerCase().includes("nv") ) { ffmpegArgs.push('-tune', 'stillimage'); } } ffmpegArgs.push( '-map', currentAudio, `-flags`, `cgop+ilme`, ); if ( transcodeVideo && (this.audioOnly !== true) ) { // add the video encoder flags ffmpegArgs.push( `-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`, `-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` ); } } if (transcodeAudio && transcodeVideo) { console.log("Video and Audio are being transcoded by ffmpeg"); } else if (transcodeVideo) { console.log("Video is being transcoded by ffmpeg. Audio is being copied."); } else if (transcodeAudio) { console.log("Audio is being transcoded by ffmpeg. Video is being copied."); } else { console.log("Video and Audio are being copied. ffmpeg is not transcoding."); } 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`, 32 /*`100000000`*/, `-i`, streamUrl ); if (this.audioOnly !== true) { ffmpegArgs.push( `-map`, `0:v` ); } ffmpegArgs.push( `-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}"`, ); //t should be before -f if (typeof duration !== 'undefined') { ffmpegArgs.push(`-t`, `${duration}`); } ffmpegArgs.push(`-f`, `mpegts`, `pipe:1`) let doLogs = this.opts.logFfmpeg && !isConcatPlaylist; if (this.hasBeenKilled) { return ; } //console.log(this.ffmpegPath + " " + ffmpegArgs.join(" ") ); this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } ); if (this.hasBeenKilled) { console.log("Send SIGKILL to ffmpeg"); this.ffmpeg.kill("SIGKILL"); return; } this.ffmpegName = (isConcatPlaylist ? "Concat FFMPEG": "Stream FFMPEG"); this.ffmpeg.on('error', (code, signal) => { console.log( `${this.ffmpegName} received error event: ${code}, ${signal}` ); }); this.ffmpeg.on('exit', (code, signal) => { if (code === null) { if (!this.hasBeenKilled) { console.log( `${this.ffmpegName} exited due to signal: ${signal}` ); } else { console.log( `${this.ffmpegName} exited due to signal: ${signal} as expected.`); } this.emit('close', code) } else if (code === 0) { console.log( `${this.ffmpegName} exited normally.` ); this.emit('end') } else if (code === 255) { if (this.hasBeenKilled) { console.log( `${this.ffmpegName} finished with code 255.` ); this.emit('close', code) return; } if (! this.sentData) { this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` }) } console.log( `${this.ffmpegName} exited with code 255.` ); this.emit('close', code) } else { console.log( `${this.ffmpegName} exited with code ${code}.` ); this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` }) } }); return this.ffmpeg.stdout; } kill() { console.log(`${this.ffmpegName} RECEIVED kill() command`); this.hasBeenKilled = true; if (typeof(this.ffmpeg) != "undefined") { console.log(`${this.ffmpegName} this.ffmpeg.kill()`); this.ffmpeg.kill("SIGKILL") } } } 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 isLargerResolution( w1,h1, w2,h2) { return (w1 > w2) || (h1 > h2) || (w1 % 2 ==1) || (h1 % 2 == 1); } function parseResolutionString(s) { var i = s.indexOf('x'); if (i == -1) { i = s.indexOf("×"); if (i == -1) { return {w:1920, h:1080} } } return { w: parseInt( s.substring(0,i) , 10 ), h: parseInt( s.substring(i+1) , 10 ), } } function gcd(a, b) { while (b != 0) { let c = b; b = a % b; a = c; } return a; } module.exports = FFMPEG