diff --git a/index.js b/index.js index 5805ef7..1cf50e0 100644 --- a/index.js +++ b/index.js @@ -282,6 +282,7 @@ app.get('/version.js', (req, res) => { app.use('/images', express.static(path.join(process.env.DATABASE, 'images'))) app.use(express.static(path.join(__dirname, 'web','public'))) app.use('/images', express.static(path.join(process.env.DATABASE, 'images'))) +app.use('/videos', express.static(path.join(process.env.DATABASE, 'videos'))) app.use('/cache/images', cacheImageService.routerInterceptor()) app.use('/cache/images', express.static(path.join(process.env.DATABASE, 'cache','images'))) app.use('/favicon.svg', express.static( diff --git a/src/api.js b/src/api.js index 7d21f4d..ed810ab 100644 --- a/src/api.js +++ b/src/api.js @@ -383,6 +383,113 @@ function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideSe } }) + router.post('/api/upload/video', async (req, res) => { + try { + if(!req.files) { + res.send({ + status: false, + message: 'No file uploaded' + }); + return; + } + + const vid = req.files.video; + // Basic validation: extension + const allowed = ['.mp4', '.m4v']; + const ext = path.extname(vid.name).toLowerCase(); + if (!allowed.includes(ext)) { + return res.status(400).send({ status: false, message: 'Unsupported file type' }); + } + + // If upload is associated to a channel, enforce 15 MB limit and store under channel hierarchy + let channelNumber = null; + try { + if (req.body && typeof(req.body.channel) !== 'undefined' && req.body.channel !== null && req.body.channel !== '') { + channelNumber = parseInt(req.body.channel, 10); + if (isNaN(channelNumber)) channelNumber = null; + } + } catch (e) { + channelNumber = null; + } + + const MAX_BYTES_CHANNEL = 15 * 1024 * 1024; + const MAX_BYTES_GENERIC = 25 * 1024 * 1024; + const maxAllowed = (channelNumber !== null) ? MAX_BYTES_CHANNEL : MAX_BYTES_GENERIC; + if (vid.size > maxAllowed) { + return res.status(400).send({ status: false, message: 'File too large' }); + } + + if (channelNumber !== null) { + const uploadDir = path.join(process.env.DATABASE, '/videos/', '' + channelNumber, '/split-screen/'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + // avoid collisions + const safeName = `${Date.now()}-${vid.name}`; + const dest = path.join(uploadDir, safeName); + await vid.mv(dest); + + const fileUrl = `${req.protocol}://${req.get('host')}/videos/${channelNumber}/split-screen/${safeName}`; + + // Persist into channel record if possible + try { + let channel = await channelService.getChannel(channelNumber); + if (channel != null) { + if (typeof(channel.splitScreen) === 'undefined' || channel.splitScreen == null) { + channel.splitScreen = { + useGlobal: false, + enabled: true, + source: fileUrl, + widthPercent: 35, + loop: true, + } + } else { + channel.splitScreen.source = fileUrl; + channel.splitScreen.useGlobal = false; + channel.splitScreen.enabled = true; + } + await channelService.saveChannel(channelNumber, channel); + } + } catch (e) { + console.error('Error persisting channel splitScreen info', e); + } + + return res.send({ + status: true, + message: 'File is uploaded', + data: { + name: safeName, + mimetype: vid.mimetype, + size: vid.size, + fileUrl: fileUrl + } + }); + } else { + // Generic uploads (no channel) -> previous behavior + const uploadDir = path.join(process.env.DATABASE, '/videos/uploads/'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + const dest = path.join(uploadDir, vid.name); + await vid.mv(dest); + + return res.send({ + status: true, + message: 'File is uploaded', + data: { + name: vid.name, + mimetype: vid.mimetype, + size: vid.size, + fileUrl: `${req.protocol}://${req.get('host')}/videos/uploads/${vid.name}` + } + }); + } + } catch (err) { + console.error('Error in /api/upload/video', err); + res.status(500).send(err); + } + }) + // Filler router.get('/api/fillers', async (req, res) => { try { diff --git a/src/database-migration.js b/src/database-migration.js index cef34a7..38f010d 100644 --- a/src/database-migration.js +++ b/src/database-migration.js @@ -411,6 +411,14 @@ function ffmpeg() { maxFPS: 60, scalingAlgorithm: "bicubic", deinterlaceFilter: "none", + // Split-screen/secondary looped video (optional) + splitScreenEnabled: false, + // URL or local path to the secondary video to loop (can be http(s) or file path) + splitScreenSource: "", + // Percentage width of the secondary (right) video relative to output width + splitScreenWidthPercent: 35, + // If true, ffmpeg will attempt to loop the secondary input (via -stream_loop) + splitScreenLoop: true, } } diff --git a/src/ffmpeg.js b/src/ffmpeg.js index c503279..b728363 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -2,12 +2,12 @@ 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; + // Clone opts so per-channel overrides don't mutate the global settings + this.opts = JSON.parse(JSON.stringify(opts || {})); this.errorPicturePath = `http://localhost:${process.env.PORT}/images/generic-error-screen.png`; this.ffmpegName = "unnamed ffmpeg"; if (! this.opts.enableFFMPEGTranscoding) { @@ -22,9 +22,9 @@ class FFMPEG extends events.EventEmitter { this.opts.maxFPS = REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE; } this.channel = channel - this.ffmpegPath = opts.ffmpegPath + this.ffmpegPath = this.opts.ffmpegPath - let resString = opts.targetResolution; + let resString = this.opts.targetResolution; if ( (typeof(channel.transcoding) !== 'undefined') && (channel.transcoding.targetResolution != null) @@ -40,7 +40,7 @@ class FFMPEG extends events.EventEmitter { && (typeof(channel.transcoding.videoBitrate) != 'undefined') && (channel.transcoding.videoBitrate != 0) ) { - opts.videoBitrate = channel.transcoding.videoBitrate; + this.opts.videoBitrate = channel.transcoding.videoBitrate; } if ( @@ -49,13 +49,36 @@ class FFMPEG extends events.EventEmitter { && (typeof(channel.transcoding.videoBufSize) != 'undefined') && (channel.transcoding.videoBufSize != 0) ) { - opts.videoBufSize = channel.transcoding.videoBufSize; + this.opts.videoBufSize = channel.transcoding.videoBufSize; } let parsed = parseResolutionString(resString); this.wantedW = parsed.w; this.wantedH = parsed.h; + // Apply per-channel transcoding overrides + if (channel && typeof(channel.transcoding) !== 'undefined') { + if (typeof channel.transcoding.targetResolution === 'string' && channel.transcoding.targetResolution !== '') { + let parsed = resolutionMap.parseResolutionString(channel.transcoding.targetResolution); + this.wantedW = parsed.w; + this.wantedH = parsed.h; + } + if (typeof channel.transcoding.videoBitrate === 'number' && channel.transcoding.videoBitrate > 0) { + this.opts.videoBitrate = channel.transcoding.videoBitrate; + } + if (typeof channel.transcoding.videoBufSize === 'number' && channel.transcoding.videoBufSize > 0) { + this.opts.videoBufSize = channel.transcoding.videoBufSize; + } + // Get videoFlip from channel transcoding settings + if (typeof channel.transcoding.videoFlip === 'string' && channel.transcoding.videoFlip !== '') { + this.opts.videoFlip = channel.transcoding.videoFlip; + } + } + // Ensure videoFlip default + if (typeof this.opts.videoFlip !== 'string') { + this.opts.videoFlip = 'none'; + } + this.sentData = false; this.apad = this.opts.normalizeAudio; this.audioChannelsSampleRate = this.opts.normalizeAudio; @@ -67,11 +90,11 @@ class FFMPEG extends events.EventEmitter { setAudioOnly(audioOnly) { this.audioOnly = audioOnly; } - async spawnConcat(streamUrl) { - return await this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true) + async spawnConcat(streamUrl, splitScreen) { + return await this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true, splitScreen) } - async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type) { - return await this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false); + async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type, splitScreen) { + return await this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false, splitScreen); } async spawnError(title, subtitle, duration) { if (! this.opts.enableFFMPEGTranscoding || this.opts.errorScreen == 'kill') { @@ -90,7 +113,7 @@ class FFMPEG extends events.EventEmitter { videoHeight : this.wantedH, duration : duration, }; - return await this.spawn({ errorTitle: title , subtitle: subtitle }, streamStats, undefined, `${streamStats.duration}ms`, true, false, 'error', false) + return await this.spawn({ errorTitle: title , subtitle: subtitle }, streamStats, undefined, `${streamStats.duration}ms`, true, false, 'error', false, null) } async spawnOffline(duration) { if (! this.opts.enableFFMPEGTranscoding) { @@ -104,10 +127,11 @@ class FFMPEG extends events.EventEmitter { videoHeight : this.wantedH, duration : duration, }; - return await this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false); + return await this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false, null); } - async spawn(streamUrl, streamStats, startTime, duration, limitRead, watermark, type, isConcatPlaylist) { + async spawn(streamUrl, streamStats, startTime, duration, limitRead, watermark, type, isConcatPlaylist, splitScreen) { + console.log("[DEBUG] FFMPEG.spawn received splitScreen:", JSON.stringify(splitScreen)); let ffmpegArgs = [ `-threads`, isConcatPlaylist? 1 : this.opts.threads, `-fflags`, `+genpts+discardcorrupt+igndts`]; @@ -319,12 +343,48 @@ class FFMPEG extends events.EventEmitter { currentVideo = "[videox]"; } if (doOverlay) { + // Support for channel watermark (image) and optional split-screen + // secondary video. If the watermark is provided as before, use it + // as an image overlay. If ffmpeg settings enable a split-screen + // secondary, add it as another input and later compose using + // filter_complex. if (watermark.animated === true) { ffmpegArgs.push('-ignore_loop', '0'); } ffmpegArgs.push(`-i`, `${watermark.url}` ); overlayFile = inputFiles++; this.ensureResolution = true; + + // If split-screen secondary is provided via parameter, attach it + if (splitScreen && splitScreen.enabled && splitScreen.source && splitScreen.source.trim() !== '' && this.audioOnly !== true) { + // If requested, try to loop the source using -stream_loop + if (splitScreen.loop === true) { + // -stream_loop value must come before the -i for the input + ffmpegArgs.push('-stream_loop', '-1'); + } + ffmpegArgs.push('-i', `${splitScreen.source}`); + var splitOverlayFile = inputFiles++; + // mark that we will need to compose the secondary video later + this._splitOverlayFile = splitOverlayFile; + this._splitScreenConfig = splitScreen; + this.ensureResolution = true; + } + } + + // If watermark handling was skipped (watermark === null) we still + // want to allow adding the split-screen secondary input. Add the + // secondary input here if it wasn't already added above. + if (typeof this._splitOverlayFile === 'undefined') { + if (splitScreen && splitScreen.enabled && splitScreen.source && splitScreen.source.trim() !== '' && this.audioOnly !== true) { + if (splitScreen.loop === true) { + ffmpegArgs.push('-stream_loop', '-1'); + } + ffmpegArgs.push('-i', `${splitScreen.source}`); + var splitOverlayFile = inputFiles++; + this._splitOverlayFile = splitOverlayFile; + this._splitScreenConfig = splitScreen; + this.ensureResolution = true; + } } // Resolution fix: Add scale filter, current stream becomes [siz] @@ -389,6 +449,15 @@ class FFMPEG extends events.EventEmitter { iH = this.wantedH; } + // Apply videoFlip to main video before watermark/split-screen composition + if (this.opts.videoFlip === 'hflip') { + videoComplex += `;${currentVideo}hflip[flipped]`; + currentVideo = '[flipped]'; + } else if (this.opts.videoFlip === 'vflip') { + videoComplex += `;${currentVideo}vflip[flipped]`; + currentVideo = '[flipped]'; + } + // Channel watermark: if (doOverlay && (this.audioOnly !== true) ) { var pW =watermark.width; @@ -425,6 +494,32 @@ class FFMPEG extends events.EventEmitter { currentVideo = '[comb]'; } + // Split-screen composition (independent of watermark) + // If we have a split-screen secondary input configured, compose + // it to the right of the main video using hstack after scaling + if (typeof(this._splitOverlayFile) !== 'undefined' && this._splitScreenConfig && this.audioOnly !== true) { + console.log("[DEBUG] Building split-screen composition..."); + try { + const splitPercent = Number(this._splitScreenConfig.widthPercent) || 35; + const sideW = Math.max(16, Math.round(this.wantedW * splitPercent / 100.0)); + const mainW = Math.max(16, this.wantedW - sideW); + console.log(`[DEBUG] Split-screen dimensions: main=${mainW}x${this.wantedH}, side=${sideW}x${this.wantedH}`); + // scale secondary to wanted height, preserving aspect + videoComplex += `;[${this._splitOverlayFile}:v]scale=${sideW}:${this.wantedH}:force_original_aspect_ratio=decrease[side_scaled]`; + // scale main (currentVideo) to remaining width and wanted height + videoComplex += `;${currentVideo}scale=${mainW}:${this.wantedH}:force_original_aspect_ratio=decrease[main_scaled]`; + // pad both to exact dimensions if necessary + videoComplex += `;[main_scaled]pad=${mainW}:${this.wantedH}:(ow-iw)/2:(oh-ih)/2[main_padded]`; + videoComplex += `;[side_scaled]pad=${sideW}:${this.wantedH}:(ow-iw)/2:(oh-ih)/2[side_padded]`; + // stack horizontally + videoComplex += `;[main_padded][side_padded]hstack=inputs=2[comb2]`; + currentVideo = '[comb2]'; + console.log("[DEBUG] Split-screen composition added to filter_complex"); + } catch (e) { + console.error("Error while building split-screen filters:", e); + } + } + if (this.volumePercent != 100) { var f = this.volumePercent / 100.0; @@ -569,7 +664,14 @@ class FFMPEG extends events.EventEmitter { if (this.hasBeenKilled) { return ; } - //console.log(this.ffmpegPath + " " + ffmpegArgs.join(" ") ); + // Log the full ffmpeg command when requested so it's easy to debug + if (this.opts.logFfmpeg) { + try { + console.log("FFMPEG CMD:", this.ffmpegPath, ffmpegArgs.join(' ')); + } catch (e) { + console.log("FFMPEG CMD (could not join args)"); + } + } this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } ); if (this.hasBeenKilled) { console.log("Send SIGKILL to ffmpeg"); diff --git a/src/helperFuncs.js b/src/helperFuncs.js index 1d905f9..3c6ab56 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -2,6 +2,7 @@ module.exports = { getCurrentProgramAndTimeElapsed: getCurrentProgramAndTimeElapsed, createLineup: createLineup, getWatermark: getWatermark, + getSplitScreen: getSplitScreen, generateChannelContext: generateChannelContext, } @@ -346,6 +347,39 @@ function getWatermark( ffmpegSettings, channel, type) { } +// Return split-screen configuration. This is intentionally independent +// from watermark logic and reads global settings with optional per-channel +// overrides when `channel.splitScreen.useGlobal` is false. +function getSplitScreen(ffmpegSettings, channel) { + let result = null; + + // Check if channel has overrides (not using global) + if (channel && typeof channel.splitScreen !== 'undefined' && channel.splitScreen !== null && channel.splitScreen.useGlobal !== true) { + // Use channel-specific settings + result = { + enabled: !!channel.splitScreen.enabled, + source: (typeof channel.splitScreen.source === 'string') ? channel.splitScreen.source : '', + widthPercent: Number(channel.splitScreen.widthPercent) || 35, + loop: !!channel.splitScreen.loop, + }; + } else { + // Use global settings + if (!ffmpegSettings || !ffmpegSettings.splitScreenEnabled) { + return null; + } + result = { + enabled: !!ffmpegSettings.splitScreenEnabled, + source: (typeof ffmpegSettings.splitScreenSource === 'string') ? ffmpegSettings.splitScreenSource : '', + widthPercent: Number(ffmpegSettings.splitScreenWidthPercent) || 35, + loop: !!ffmpegSettings.splitScreenLoop, + }; + } + + if (!result.enabled || !result.source || result.source.trim() === '') return null; + return result; +} + + function getFillerMedian(programPlayTime, channel, filler) { let times = []; diff --git a/src/plex-player.js b/src/plex-player.js index 9a75f84..d9ecb15 100644 --- a/src/plex-player.js +++ b/src/plex-player.js @@ -61,6 +61,7 @@ class PlexPlayer { let plexTranscoder = new PlexTranscoder(this.clientId, server, plexSettings, channel, lineupItem); this.plexTranscoder = plexTranscoder; let watermark = this.context.watermark; + let splitScreen = this.context.splitScreen; let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options ffmpeg.setAudioOnly( this.context.audioOnly ); this.ffmpeg = ffmpeg; @@ -85,7 +86,7 @@ class PlexPlayer { let emitter = new EventEmitter(); //setTimeout( () => { - let ff = await ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, watermark, lineupItem.type); // Spawn the ffmpeg process + let ff = await ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, watermark, lineupItem.type, splitScreen); // Spawn the ffmpeg process ff.pipe(outStream, {'end':false} ); //}, 100); plexTranscoder.startUpdatingPlex(); diff --git a/src/program-player.js b/src/program-player.js index d7ceea4..dc380aa 100644 --- a/src/program-player.js +++ b/src/program-player.js @@ -58,6 +58,9 @@ class ProgramPlayer { this.delegate = new PlexPlayer(context); } this.context.watermark = helperFuncs.getWatermark( context.ffmpegSettings, context.channel, context.lineupItem.type); + // Compute split-screen separately from watermark so it's independent + // of overlay settings and filler overlay disabling. + this.context.splitScreen = helperFuncs.getSplitScreen( context.ffmpegSettings, context.channel ); } cleanUp() { diff --git a/src/video.js b/src/video.js index 7d2047f..a5a43e7 100644 --- a/src/video.js +++ b/src/video.js @@ -125,7 +125,13 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS }) let channelNum = parseInt(req.query.channel, 10) - let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}&audioOnly=${audioOnly}&stepNumber={step}`); + // For concat mode, attempt to read splitScreen from channel + let splitScreen = null; + try { + const helperFuncs = require('./helperFuncs'); + splitScreen = helperFuncs.getSplitScreen(ffmpegSettings, channel); + } catch (e) {} + let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}&audioOnly=${audioOnly}&stepNumber={step}`, splitScreen); ff.pipe(res, { end: false} ); }; router.get('/video', async(req, res) => { diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index d8da0fe..184e492 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -86,6 +86,16 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get scope.showRotatedNote = false; scope.channel.transcoding = { targetResolution: "", + videoFlip: 'none', + } + scope.channel.splitScreen = { + // If true, use the global ffmpeg split-screen settings instead + // of the channel specific ones + useGlobal: true, + enabled: false, + source: "", + widthPercent: 35, + loop: true, } scope.channel.onDemand = { isOnDemand : false, @@ -137,7 +147,21 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get } if (typeof(scope.channel.transcoding) ==='undefined') { - scope.channel.transcoding = {}; + scope.channel.transcoding = { + videoFlip: 'none', + }; + } + if (typeof(scope.channel.transcoding.videoFlip) === 'undefined') { + scope.channel.transcoding.videoFlip = 'none'; + } + if (typeof(scope.channel.splitScreen) === 'undefined') { + scope.channel.splitScreen = { + useGlobal: true, + enabled: false, + source: "", + widthPercent: 35, + loop: true, + } } if ( (scope.channel.transcoding.targetResolution == null) @@ -1796,6 +1820,20 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get }) } + scope.splitScreenOnChange = (event) => { + const formData = new FormData(); + formData.append('video', event.target.files[0]); + // include channel number so server stores file under channel hierarchy and persists it + if (typeof(scope.channel) !== 'undefined' && scope.channel != null && typeof(scope.channel.number) !== 'undefined') { + formData.append('channel', scope.channel.number); + } + dizquetv.uploadVideo(formData).then((response) => { + scope.channel.splitScreen.source = response.data.fileUrl; + }).catch((err) => { + console.error('Error uploading split-screen video', err); + }) + } + }, diff --git a/web/directives/ffmpeg-settings.js b/web/directives/ffmpeg-settings.js index e474097..fd236f7 100644 --- a/web/directives/ffmpeg-settings.js +++ b/web/directives/ffmpeg-settings.js @@ -9,11 +9,14 @@ module.exports = function (dizquetv, resolutionOptions) { //add validations to ffmpeg settings, speciall commas in codec name dizquetv.getFfmpegSettings().then((settings) => { scope.settings = settings + // ensure videoFlip default exists + scope.settings.videoFlip = scope.settings.videoFlip || 'none'; }) scope.updateSettings = (settings) => { delete scope.settingsError; dizquetv.updateFfmpegSettings(settings).then((_settings) => { scope.settings = _settings + scope.settings.videoFlip = scope.settings.videoFlip || 'none'; }).catch( (err) => { if ( typeof(err.data) === "string") { scope.settingsError = err.data; @@ -23,6 +26,7 @@ module.exports = function (dizquetv, resolutionOptions) { scope.resetSettings = (settings) => { dizquetv.resetFfmpegSettings(settings).then((_settings) => { scope.settings = _settings + scope.settings.videoFlip = scope.settings.videoFlip || 'none'; }) } scope.isTranscodingNotNeeded = () => { @@ -80,6 +84,18 @@ module.exports = function (dizquetv, resolutionOptions) { {value: "yadif=1", description: "yadif send field"} ]; + scope.uploadGlobalSplitVideoOnChange = (event) => { + const formData = new FormData(); + formData.append('video', event.target.files[0]); + // No channel parameter -> generic upload + dizquetv.uploadVideo(formData).then((response) => { + scope.settings.splitScreenSource = response.data.fileUrl; + scope.$applyAsync(); + }).catch((err) => { + console.error('Error uploading global split-screen video', err); + }) + } + } } } \ No newline at end of file diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index 081300d..cd6e3a0 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -758,6 +758,45 @@ Renders a channel icon (also known as bug or Digital On-screen Graphic) on top of the channel's stream. +