diff --git a/Dockerfile b/Dockerfile index f173afc..6e3f458 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,42 @@ FROM node:12.18-alpine3.12 # Should be ffmpeg v4.2.3 RUN apk add --no-cache ffmpeg && ffmpeg -version + +# Remove the previous line and uncommenting the following lines will allow the +# ffmpeg version to support draw_text filter, but it makes the docker build take +# a long time and it's only used for minor features at the moment. +#RUN apk add --update \ +# curl yasm build-base gcc zlib-dev libc-dev openssl-dev yasm-dev lame-dev libogg-dev x264-dev libvpx-dev libvorbis-dev x265-dev freetype-dev libass-dev libwebp-dev rtmpdump-dev libtheora-dev opus-dev && \ +# DIR=$(mktemp -d) && cd ${DIR} && \ +# curl -s http://ffmpeg.org/releases/ffmpeg-4.2.3.tar.gz | tar zxvf - -C . && \ +# cd ffmpeg-4.2.3 && \ +# ./configure \ +# --enable-version3 \ +# --enable-gpl \ +# --enable-nonfree \ +# --enable-small \ +# --enable-libmp3lame \ +# --enable-libx264 \ +# --enable-libx265 \ +# --enable-libvpx \ +# --enable-libtheora \ +# --enable-libvorbis \ +# --enable-libopus \ +# --enable-libass \ +# --enable-libwebp \ +# --enable-librtmp \ +# --enable-postproc \ +# --enable-avresample \ +# --enable-libfreetype \ +# --enable-openssl \ +# --enable-filter=drawtext \ +# --disable-debug && \ +# make && \ +# make install && \ +# make distclean && \ +# rm -rf ${DIR} && \ +# mv /usr/local/bin/ffmpeg /usr/bin/ffmpeg && \ +# apk del build-base curl tar bzip2 x264 openssl nasm openssl xz gnupg && rm -rf /v WORKDIR /home/node/app COPY package*.json ./ RUN npm install diff --git a/index.js b/index.js index 4e8a08b..157540d 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,7 @@ const express = require('express') const bodyParser = require('body-parser') const api = require('./src/api') +const defaultSettings = require('./src/defaultSettings') const video = require('./src/video') const HDHR = require('./src/hdhr') @@ -103,19 +104,13 @@ function initDB(db) { let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/pseudotv.png'))) fs.writeFileSync(process.env.DATABASE + '/images/pseudotv.png', data) } + if (!fs.existsSync(process.env.DATABASE + '/images/generic-error-screen.png')) { + let data = fs.readFileSync(path.resolve(path.join(__dirname, 'resources/generic-error-screen.png'))) + fs.writeFileSync(process.env.DATABASE + '/images/generic-error-screen.png', data) + } - if (ffmpegSettings.length === 0) { - db['ffmpeg-settings'].save({ - ffmpegPath: '/usr/bin/ffmpeg', - enableChannelOverlay: false, - threads: 4, - videoEncoder: 'mpeg2video', - videoResolutionHeight: 'unchanged', - videoBitrate: 10000, - videoBufSize: 2000, - concatMuxDelay: '0', - logFfmpeg: true - }) + if ( (ffmpegSettings.length === 0) || typeof(ffmpegSettings.configVersion) === 'undefined' ) { + db['ffmpeg-settings'].save( defaultSettings.ffmpeg() ) } if (plexSettings.length === 0) { diff --git a/resources/generic-error-screen.png b/resources/generic-error-screen.png new file mode 100644 index 0000000..fbd261f Binary files /dev/null and b/resources/generic-error-screen.png differ diff --git a/src/api.js b/src/api.js index 5bff62b..0b4b004 100644 --- a/src/api.js +++ b/src/api.js @@ -1,6 +1,7 @@ const express = require('express') const fs = require('fs') +const defaultSettings = require('./defaultSettings') module.exports = { router: api } function api(db, xmltvInterval) { @@ -62,18 +63,10 @@ function api(db, xmltvInterval) { res.send(ffmpeg) }) router.post('/api/ffmpeg-settings', (req, res) => { // RESET - db['ffmpeg-settings'].update({ _id: req.body._id }, { - ffmpegPath: req.body.ffmpegPath, - enableChannelOverlay: false, - threads: 4, - videoEncoder: 'mpeg2video', - videoResolutionHeight: 'unchanged', - videoBitrate: 10000, - videoBufSize: 2000, - concatMuxDelay: '0', - logFfmpeg: true - }) - let ffmpeg = db['ffmpeg-settings'].find()[0] + let ffmpeg = defaultSettings.ffmpeg(); + ffmpeg.ffmpegPath = req.body.ffmpegPath; + db['ffmpeg-settings'].update({ _id: req.body._id }, ffmpeg) + ffmpeg = db['ffmpeg-settings'].find()[0] res.send(ffmpeg) }) diff --git a/src/defaultSettings.js b/src/defaultSettings.js new file mode 100644 index 0000000..bc19291 --- /dev/null +++ b/src/defaultSettings.js @@ -0,0 +1,31 @@ +module.exports = { + + ffmpeg: () => { + return { + //a record of the config version will help migrating between versions + // in the future. Always increase the version when new ffmpeg configs + // are added. + // + // configVersion 3: First versioned config. + // + configVersion: 3, + ffmpegPath: "/usr/bin/ffmpeg", + threads: 4, + concatMuxDelay: "0", + logFfmpeg: false, + enableFFMPEGTranscoding: false, + audioVolumePercent: 100, + videoEncoder: "mpeg2video", + audioEncoder: "ac3", + targetResolution: "1920x1080", + videoBitrate: 10000, + videoBufSize: 2000, + errorScreen: "pic", + errorAudio: "silent", + normalizeVideoCodec: false, + normalizeAudioCodec: false, + normalizeResolution: false, + alignAudio: false, + } + } +} diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 681233b..033a62c 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -1,31 +1,26 @@ 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'; +//they can customize this by modifying the picture in .pseudotv folder const ERROR_PICTURE_PATH = 'http://localhost:8000/images/generic-error-screen.png' +const MAXIMUM_ERROR_DURATION_MS = 60000; + class FFMPEG extends events.EventEmitter { constructor(opts, channel) { super() this.opts = opts this.channel = channel this.ffmpegPath = opts.ffmpegPath - this.alignAudio = ALIGN_AUDIO; + + var parsed = parseResolutionString(opts.targetResolution); + this.wantedW = parsed.w; + this.wantedH = parsed.h; + + this.sentData = false; + this.alignAudio = this.opts.alignAudio; + this.ensureResolution = this.opts.normalizeResolution; + this.volumePercent = this.opts.audioVolumePercent; } async spawnConcat(streamUrl) { this.spawn(streamUrl, undefined, undefined, undefined, false, false, undefined, true) @@ -34,13 +29,27 @@ class FFMPEG extends events.EventEmitter { 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 + if (! this.opts.enableFFMPEGTranscoding || this.opts.errorScreen == 'kill') { console.log("error: " + title + " ; " + subtitle); this.emit('error', { code: -1, cmd: `error stream disabled` }) return; + } + // since this is from an error situation, streamStats may have issues. + if ( (streamStats == null) || (typeof(streamStats) === 'undefined') ) { + streamStats = {}; + } + streamStats.videoWidth = this.wantedW; + streamStats.videoHeight = this.wantedH; + if ( (typeof(streamStats.duration) === 'undefined') || isNaN(streamStats.duration) || (streamStats.duration > MAXIMUM_ERROR_DURATION_MS) ) { + // it's possible that whatever issue there was when attempting to download the video from plex + // could be temporary, so it'd be better to retry after a minute + streamStats.duration = MAXIMUM_ERROR_DURATION_MS; + } + this.spawn({ errorTitle: title , subtitle: subtitle }, streamStats, undefined, `${streamStats.duration}ms`, true, enableIcon, type, false) } async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) { - let ffmpegArgs = [`-threads`, this.opts.threads, + let ffmpegArgs = [ + `-threads`, this.opts.threads, `-fflags`, `+genpts+discardcorrupt+igndts`]; if (limitRead === true) @@ -73,8 +82,9 @@ class FFMPEG extends events.EventEmitter { 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:v]null[video]`; + 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 @@ -83,17 +93,83 @@ class FFMPEG extends events.EventEmitter { // When adding filters, make sure that // videoComplex always begins wiht ; and doesn't end with ; - // prepare input files, overlay adds another input file - ffmpegArgs.push(`-i`, streamUrl); + // 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.alignAudio = false; //all of these generate audio correctly-aligned to video so there is no need for apad + + 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 (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}`, + '-pix_fmt' , 'yuv420p' + ); + 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', `${ERROR_PICTURE_PATH}`, + '-pix_fmt' , 'yuv420p' + ); + videoComplex = `;[0:0]scale=${iW}:${iH}[videoy];[videoy]realtime[videox]`; + } + let durstr = `duration=${streamStats.duration}ms`; + 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=-65dB[audioy]`; + } else { //silent + audioComplex = `;aevalsrc=0:${durstr}[audioy]`; + } + 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 (FIX_RESOLUTION && (iW != W || iH != H) ) { + if (this.ensureResolution && (iW != this.wantedW || iH != this.wantedH) ) { //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]` + 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: @@ -110,6 +186,11 @@ class FFMPEG extends events.EventEmitter { 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.alignAudio) { audioComplex += `;${currentAudio}apad=whole_dur=${streamStats.duration}ms[padded]`; @@ -119,18 +200,18 @@ class FFMPEG extends events.EventEmitter { // 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 changeVideoCodec = (this.opts.normalizeVideoCodec && isDifferentVideoCodec( streamStats.videoCodec, this.opts.videoEncoder) ); + var changeAudioCodec = (this.opts.normalizeAudioCodec && isDifferentAudioCodec( streamStats.audioCodec, this.opts.audioEncoder) ); var filterComplex = ''; if (currentVideo != '[video]') { - changeVideoCodec = true; + changeVideoCodec = true; //this is useful so that it adds some lines below filterComplex += videoComplex; } else { - currentVideo = '0:v'; + currentVideo = `0:${videoIndex}`; } - // same with audi: + // same with audio: if (currentAudio != '[audio]') { - changeAudioCodec = true; //this is useful for some more flags later + changeAudioCodec = true; filterComplex += audioComplex; } else { currentAudio = `0:${audioIndex}`; @@ -161,13 +242,14 @@ class FFMPEG extends events.EventEmitter { ); } ffmpegArgs.push( - `-c:a`, (changeAudioCodec ? AUDIO_ENCODER : 'copy'), + `-c:a`, (changeAudioCodec ? this.opts.audioEncoder : 'copy'), `-muxdelay`, `0`, `-muxpreload`, `0` ); } else { //Concat stream is simpler and should always copy the codec ffmpegArgs.push( + `-probesize`, `25000000`, `-i`, streamUrl, `-map`, `0:v`, `-map`, `0:${audioIndex}`, @@ -191,6 +273,7 @@ class FFMPEG extends events.EventEmitter { this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs) this.ffmpeg.stdout.on('data', (chunk) => { + this.sentData = true; this.emit('data', chunk) }) if (this.opts.logFfmpeg) { @@ -199,14 +282,18 @@ class FFMPEG extends events.EventEmitter { }) } this.ffmpeg.on('close', (code) => { - if (code === null) + if (code === null) { this.emit('close', code) - else if (code === 0) + } else if (code === 0) { this.emit('end') - else if (code === 255) + } else if (code === 255) { + if (! this.sentData) { + this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` }) + } this.emit('close', code) - else + } else { this.emit('error', { code: code, cmd: `${this.opts.ffmpegPath} ${ffmpegArgs.join(' ')}` }) + } }) } kill() { @@ -216,4 +303,42 @@ class FFMPEG extends events.EventEmitter { } } +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 diff --git a/src/helperFuncs.js b/src/helperFuncs.js index cc5a665..c772a06 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -113,8 +113,15 @@ function createLineup(obj) { return lineup } -function isChannelIconEnabled(enableChannelOverlay, icon, overlayIcon, type) { - if (typeof type === `undefined`) - return enableChannelOverlay == true && icon !== '' && overlayIcon - return enableChannelOverlay == true && icon !== '' && overlayIcon +function isChannelIconEnabled( ffmpegSettings, channel, type) { + if (! ffmpegSettings.enableFFMPEGTranscoding || ffmpegSettings.disableChannelOverlay ) { + return false; + } + if ( (typeof type !== `undefined`) && (type == 'commercial') ) { + return false; + } + if (channel.icon === '' || !channel.overlayIcon) { + return false; + } + return true; } diff --git a/src/svg/generic-error-screen.svg b/src/svg/generic-error-screen.svg new file mode 100644 index 0000000..192ef64 --- /dev/null +++ b/src/svg/generic-error-screen.svg @@ -0,0 +1,731 @@ + + + + diff --git a/src/video.js b/src/video.js index 57b8cd3..f09bc60 100644 --- a/src/video.js +++ b/src/video.js @@ -31,7 +31,7 @@ function video(db) { return }) ffmpeg.on('close', () => { - res.send() + res.end() }) res.on('close', () => { // on HTTP close, kill ffmpeg @@ -80,7 +80,7 @@ function video(db) { }) ffmpeg.on('close', () => { - res.send(); + res.end(); }) res.on('close', () => { // on HTTP close, kill ffmpeg @@ -129,25 +129,38 @@ function video(db) { let lineup = helperFuncs.createLineup(prog) let lineupItem = lineup.shift() + let streamDuration = lineupItem.streamDuration / 1000; // Only episode in this lineup, or item is a commercial, let stream end naturally if (lineup.length === 0 || lineupItem.type === 'commercial' || lineup.length === 1 && lineup[0].type === 'commercial') streamDuration = undefined - let deinterlace = enableChannelIcon = helperFuncs.isChannelIconEnabled(ffmpegSettings.enableChannelOverlay, channel.icon, channel.overlayIcon) + let enableChannelIcon = helperFuncs.isChannelIconEnabled( ffmpegSettings, channel, lineupItem.type); + let deinterlace = ffmpegSettings.enableFFMPEGTranscoding; //for now it will always deinterlace when transcoding is enabled but this is sub-optimal let plexTranscoder = new PlexTranscoder(plexSettings, lineupItem); let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options + var ffmpeg1Ended = false; ffmpeg.on('data', (data) => { res.write(data) }) ffmpeg.on('error', (err) => { + if (ffmpeg1Ended) { + return; + } + ffmpeg1Ended = true; 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('data', (data) => { + try { + res.write(data) + } catch (err) { + console.log("err="+err); + } + } ); ffmpeg2.on('error', (err) => { res.end() } ); ffmpeg2.on('close', () => { res.send() } ); ffmpeg2.on('end', () => { res.end() } ); @@ -160,11 +173,17 @@ function video(db) { }) ffmpeg.on('close', () => { + if (ffmpeg1Ended) { + return; + } plexTranscoder.stopUpdatingPlex(); - res.send(); + res.end(); }) ffmpeg.on('end', () => { // On finish transcode - END of program or commercial... + if (ffmpeg1Ended) { + return; + } plexTranscoder.stopUpdatingPlex(); res.end() }) @@ -178,14 +197,13 @@ function video(db) { let streamStart = (stream.directPlay) ? plexTranscoder.currTimeS : undefined; - let streamStats = stream.streamStats + let streamStats = stream.streamStats; + streamStats.duration = lineupItem.streamDuration; console.log("timeElapsed=" + prog.timeElapsed ); - streamStats.duration = streamStats.duration - prog.timeElapsed; this.backup = { stream: stream, streamStart: streamStart, - streamDuration: streamDuration, enableChannelIcon: enableChannelIcon, type: lineupItem.type }; @@ -214,7 +232,7 @@ function video(db) { // If someone passes this number then they probably watch too much television let maxStreamsToPlayInARow = 100; - var data = "#ffconcat version 1.0\n" + var data = "ffconcat version 1.0\n" for (var i = 0; i < maxStreamsToPlayInARow; i++) data += `file 'http://localhost:${process.env.PORT}/stream?channel=${channelNum}'\n` diff --git a/web/directives/ffmpeg-settings.js b/web/directives/ffmpeg-settings.js index f8e2fcd..1ceb042 100644 --- a/web/directives/ffmpeg-settings.js +++ b/web/directives/ffmpeg-settings.js @@ -19,21 +19,22 @@ scope.settings = _settings }) } - scope.hideIfNotEnableChannelOverlay = () => { - return scope.settings.enableChannelOverlay != true + scope.isTranscodingNotNeeded = () => { + return ! (scope.settings.enableFFMPEGTranscoding) }; scope.hideIfNotAutoPlay = () => { return scope.settings.enableAutoPlay != true }; scope.resolutionOptions=[ - {id:"420",description:"420x420"}, - {id:"320",description:"576x320"}, - {id:"480",description:"720x480"}, - {id:"768",description:"1024x768"}, - {id:"720",description:"1280x720"}, - {id:"1080",description:"1920x1080"}, - {id:"2160",description:"3840x2160"}, - {id:"unchanged",description:"Same as source"} + {id:"420x420",description:"420x420 (1:1)"}, + {id:"576x320",description:"576x320 (18:10)"}, + {id:"640×360",description:"640×360 (nHD 16:9)"}, + {id:"720x480",description:"720x480 (WVGA 3:2)"}, + {id:"800x600",description:"800x600 (SVGA 4:3)"}, + {id:"1024x768",description:"1024x768 (WXGA 4:3)"}, + {id:"1280x720",description:"1280x720 (HD 16:9)"}, + {id:"1920x1080",description:"1920x1080 (FHD 16:9)"}, + {id:"3840x2160",description:"3840x2160 (4K 16:9)"}, ]; scope.muxDelayOptions=[ {id:"0",description:"0 Seconds"}, @@ -41,8 +42,22 @@ {id:"2",description:"2 Seconds"}, {id:"3",description:"3 Seconds"}, {id:"4",description:"4 Seconds"}, - {id:"5",description:"5 Seconds"} + {id:"5",description:"5 Seconds"}, + {id:"10",description:"10 Seconds"}, ]; + scope.errorScreens = [ + {value:"pic", description:"images/generic-error-screen.png"}, + {value:"blank", description:"Blank Screen"}, + {value:"static", description:"Static"}, + {value:"testsrc", description:"Test Pattern (color bars + timer)"}, + {value:"text", description:"Detailed error (requires ffmpeg with drawtext)"}, + {value:"kill", description:"Stop stream, show errors in logs"}, + ] + scope.errorAudios = [ + {value:"whitenoise", description:"White Noise"}, + {value:"sine", description:"Beep"}, + {value:"silent", description:"No Audio"}, + ] } } } \ No newline at end of file diff --git a/web/public/templates/ffmpeg-settings.html b/web/public/templates/ffmpeg-settings.html index 48b8dbd..e400170 100644 --- a/web/public/templates/ffmpeg-settings.html +++ b/web/public/templates/ffmpeg-settings.html @@ -31,14 +31,8 @@ -