Merge pull request #68 from vexorian/development
Update plex status to stopped in more situations. Code for resolution/codec normalization and alignment, but currently disabled by constants in ffmpeg.js
This commit is contained in:
commit
70da29a243
184
src/ffmpeg.js
184
src/ffmpeg.js
@ -2,18 +2,42 @@ const spawn = require('child_process').spawn
|
||||
const events = require('events')
|
||||
const fs = require('fs')
|
||||
|
||||
// For now these options can be enabled with constants, must also enable overlay in settings:
|
||||
|
||||
// Normalize resoltion to WxH:
|
||||
const FIX_RESOLUTION = false;
|
||||
const W = 1920;
|
||||
const H = 1080;
|
||||
// Normalize codecs, video codec is in ffmpeg settings:
|
||||
const FIX_CODECS = false;
|
||||
|
||||
// Align audio and video channels
|
||||
const ALIGN_AUDIO = false;
|
||||
|
||||
// What audio encoder to use:
|
||||
const AUDIO_ENCODER = 'aac';
|
||||
|
||||
const ERROR_PICTURE_PATH = 'http://localhost:8000/images/generic-error-screen.png'
|
||||
|
||||
class FFMPEG extends events.EventEmitter {
|
||||
constructor(opts, channel) {
|
||||
super()
|
||||
this.opts = opts
|
||||
this.channel = channel
|
||||
this.ffmpegPath = opts.ffmpegPath
|
||||
this.alignAudio = ALIGN_AUDIO;
|
||||
}
|
||||
async spawnConcat(streamUrl) {
|
||||
this.spawn(streamUrl, undefined, undefined, undefined, false, false, undefined, true)
|
||||
}
|
||||
async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type) {
|
||||
this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false)
|
||||
this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false);
|
||||
}
|
||||
async spawnError(title, subtitle, streamStats, enableIcon, type) {
|
||||
// currently the error stream feature is not implemented
|
||||
console.log("error: " + title + " ; " + subtitle);
|
||||
this.emit('error', { code: -1, cmd: `error stream disabled` })
|
||||
return;
|
||||
}
|
||||
async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) {
|
||||
let ffmpegArgs = [`-threads`, this.opts.threads,
|
||||
@ -22,8 +46,6 @@ class FFMPEG extends events.EventEmitter {
|
||||
if (limitRead === true)
|
||||
ffmpegArgs.push(`-re`)
|
||||
|
||||
if (typeof duration !== 'undefined')
|
||||
ffmpegArgs.push(`-t`, duration)
|
||||
|
||||
if (typeof startTime !== 'undefined')
|
||||
ffmpegArgs.push(`-ss`, startTime)
|
||||
@ -33,67 +55,139 @@ class FFMPEG extends events.EventEmitter {
|
||||
`-safe`, `0`,
|
||||
`-protocol_whitelist`, `file,http,tcp,https,tcp,tls`)
|
||||
|
||||
ffmpegArgs.push(`-i`, streamUrl)
|
||||
|
||||
// Map correct audio index. '?' so doesn't fail if no stream available.
|
||||
let audioIndex = (typeof streamStats === 'undefined') ? '0:a?' : `0:${streamStats.audioIndex}?`;
|
||||
let audioIndex = (typeof streamStats === 'undefined') ? 'a' : `${streamStats.audioIndex}`;
|
||||
|
||||
// Overlay icon
|
||||
if (enableIcon && type === 'program') {
|
||||
if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
|
||||
//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 && type === 'program');
|
||||
var iW = streamStats.videoWidth;
|
||||
var iH = streamStats.videoHeight;
|
||||
|
||||
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 = ''
|
||||
// (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 audioComplex = `;[0:${audioIndex}]anull[audio]`;
|
||||
var videoComplex = `;[0:v]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 (this.channel.iconDuration > 0)
|
||||
icnDur = `:enable='between(t,0,${this.channel.iconDuration})'`
|
||||
|
||||
let iconOverlay = `[1:v]scale=${this.channel.iconWidth}:-1[icn];[0:v][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[outv]`
|
||||
// Only scale video if specified, don't upscale video
|
||||
if (this.opts.videoResolutionHeight != "unchanged" && streamStats.videoHeight != `undefined` && parseInt(this.opts.videoResolutionHeight, 10) < parseInt(streamStats.videoHeight, 10)) {
|
||||
iconOverlay = `[0:v]scale=-2:${this.opts.videoResolutionHeight}[scaled];[1:v]scale=${this.channel.iconWidth}:-1[icn];[scaled][icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[outv]`
|
||||
// prepare input files, overlay adds another input file
|
||||
ffmpegArgs.push(`-i`, streamUrl);
|
||||
if (doOverlay) {
|
||||
ffmpegArgs.push(`-i`, `${this.channel.icon}` );
|
||||
}
|
||||
|
||||
ffmpegArgs.push(`-i`, `${this.channel.icon}`,
|
||||
`-filter_complex`, iconOverlay,
|
||||
`-map`, `[outv]`,
|
||||
`-c:v`, this.opts.videoEncoder,
|
||||
// Resolution fix: Add scale filter, current stream becomes [siz]
|
||||
if (FIX_RESOLUTION && (iW != W || iH != H) ) {
|
||||
//Maybe the scaling algorithm could be configurable. bicubic seems good though
|
||||
videoComplex += `;${currentVideo}scale=${W}:${H}:flags=bicubic:force_original_aspect_ratio=decrease,pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2[siz]`
|
||||
currentVideo = "[siz]";
|
||||
}
|
||||
|
||||
// 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]';
|
||||
}
|
||||
|
||||
// Align audio is just the apad filter applied to audio stream
|
||||
if (this.alignAudio) {
|
||||
audioComplex += `;${currentAudio}apad=whole_dur=${streamStats.duration}ms[padded]`;
|
||||
currentAudio = '[padded]';
|
||||
}
|
||||
|
||||
// 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 changeVideoCodec = FIX_CODECS;
|
||||
var changeAudioCodec = FIX_CODECS;
|
||||
var filterComplex = '';
|
||||
if (currentVideo != '[video]') {
|
||||
changeVideoCodec = true;
|
||||
filterComplex += videoComplex;
|
||||
} else {
|
||||
currentVideo = '0:v';
|
||||
}
|
||||
// same with audi:
|
||||
if (currentAudio != '[audio]') {
|
||||
changeAudioCodec = true; //this is useful for some more flags later
|
||||
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`, (changeVideoCodec ? this.opts.videoEncoder : 'copy'),
|
||||
`-flags`, `cgop+ilme`,
|
||||
`-sc_threshold`, `1000000000`,
|
||||
`-sc_threshold`, `1000000000`
|
||||
);
|
||||
if ( changeVideoCodec ) {
|
||||
// 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`,
|
||||
`-map`, `${audioIndex}`,
|
||||
`-c:a`, `copy`,
|
||||
`-bufsize:v`, `${this.opts.videoBufSize}k`
|
||||
);
|
||||
}
|
||||
ffmpegArgs.push(
|
||||
`-c:a`, (changeAudioCodec ? AUDIO_ENCODER : 'copy'),
|
||||
`-muxdelay`, `0`,
|
||||
`-muxpreload`, `0`);
|
||||
} else if (enableIcon && streamStats.videoCodec != this.opts.videoEncoder) { // Encode commercial if video codec does not match
|
||||
ffmpegArgs.push(`-map`, `0:v`,
|
||||
`-map`, `${audioIndex}`,
|
||||
`-c:v`, this.opts.videoEncoder,
|
||||
`-flags`, `cgop+ilme`,
|
||||
`-sc_threshold`, `1000000000`,
|
||||
`-b:v`, `${this.opts.videoBitrate}k`,
|
||||
`-minrate:v`, `${this.opts.videoBitrate}k`,
|
||||
`-maxrate:v`, `${this.opts.videoBitrate}k`,
|
||||
`-bufsize:v`, `${this.opts.videoBufSize}k`,
|
||||
`-c:a`, `copy`,
|
||||
`-muxdelay`, `0`,
|
||||
`-muxpreload`, `0`);
|
||||
} else
|
||||
ffmpegArgs.push(`-map`, `0:v`,
|
||||
`-map`, `${audioIndex}`,
|
||||
`-muxpreload`, `0`
|
||||
);
|
||||
} else {
|
||||
//Concat stream is simpler and should always copy the codec
|
||||
ffmpegArgs.push(
|
||||
`-i`, streamUrl,
|
||||
`-map`, `0:v`,
|
||||
`-map`, `0:${audioIndex}`,
|
||||
`-c`, `copy`,
|
||||
`-muxdelay`, this.opts.concatMuxDelay,
|
||||
`-muxpreload`, this.opts.concatMuxDelay);
|
||||
}
|
||||
|
||||
ffmpegArgs.push(`-metadata`,
|
||||
`service_provider="PseudoTV"`,
|
||||
`-metadata`,
|
||||
`service_name="${this.channel.name}`,
|
||||
`-f`, `mpegts`,
|
||||
`pipe:1`)
|
||||
`-f`, `mpegts`);
|
||||
|
||||
//t should be before output
|
||||
if (typeof duration !== 'undefined') {
|
||||
ffmpegArgs.push(`-t`, duration)
|
||||
}
|
||||
|
||||
ffmpegArgs.push(`pipe:1`)
|
||||
|
||||
this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs)
|
||||
this.ffmpeg.stdout.on('data', (chunk) => {
|
||||
|
||||
@ -168,6 +168,7 @@ lang=en`
|
||||
try {
|
||||
let streams = this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].Stream
|
||||
|
||||
ret.duration = parseFloat( this.decisionJson.MediaContainer.Metadata[0].Media[0].Part[0].duration );
|
||||
streams.forEach(function (stream) {
|
||||
// Video
|
||||
if (stream["streamType"] == "1") {
|
||||
|
||||
38
src/video.js
38
src/video.js
@ -138,18 +138,29 @@ function video(db) {
|
||||
let deinterlace = enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon)
|
||||
|
||||
let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem);
|
||||
|
||||
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
|
||||
|
||||
ffmpeg.on('data', (data) => { res.write(data) })
|
||||
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error("FFMPEG ERROR", err);
|
||||
res.status(500).send("FFMPEG ERROR");
|
||||
return;
|
||||
plexTranscoder.stopUpdatingPlex();
|
||||
if (typeof(this.backup) !== 'undefined') {
|
||||
let ffmpeg2 = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
|
||||
ffmpeg2.spawnError('Source error', `ffmpeg returned code ${err.code}`, this.backup.stream.streamStats, this.backup.enableChannelIcon, this.backup.type); // Spawn the ffmpeg process, fire this bitch up
|
||||
ffmpeg2.on('data', (data) => { res.write(data) } );
|
||||
ffmpeg2.on('error', (err) => { res.end() } );
|
||||
ffmpeg2.on('close', () => { res.send() } );
|
||||
ffmpeg2.on('end', () => { res.end() } );
|
||||
res.on('close', () => {
|
||||
ffmpeg2.kill();
|
||||
});
|
||||
} else {
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
ffmpeg.on('close', () => {
|
||||
plexTranscoder.stopUpdatingPlex();
|
||||
res.send();
|
||||
})
|
||||
|
||||
@ -159,14 +170,29 @@ function video(db) {
|
||||
})
|
||||
|
||||
res.on('close', () => { // on HTTP close, kill ffmpeg
|
||||
plexTranscoder.stopUpdatingPlex();
|
||||
ffmpeg.kill();
|
||||
})
|
||||
|
||||
plexTranscoder.getStream(deinterlace).then(stream => {
|
||||
|
||||
let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined;
|
||||
|
||||
let streamStats = stream.streamStats
|
||||
console.log("timeElapsed=" + prog.timeElapsed );
|
||||
streamStats.duration = streamStats.duration - prog.timeElapsed;
|
||||
|
||||
this.backup = {
|
||||
stream: stream,
|
||||
streamStart: streamStart,
|
||||
streamDuration: streamDuration,
|
||||
enableChannelIcon: enableChannelIcon,
|
||||
type: lineupItem.type
|
||||
};
|
||||
|
||||
ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, enableChannelIcon, lineupItem.type); // Spawn the ffmpeg process, fire this bitch up
|
||||
plexTranscoder.startUpdatingPlex();
|
||||
});
|
||||
plexTranscoder.startUpdatingPlex();
|
||||
});
|
||||
})
|
||||
router.get('/playlist', (req, res) => {
|
||||
res.type('text')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user