diff --git a/public/css/main.css b/public/css/main.css index 5af3e93..3396145 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -4148,6 +4148,12 @@ kbd { /* Green for Direct */ } +.transcode-status.upscaling { + background: rgba(156, 39, 176, 0.8); + /* Purple for Upscaling */ + box-shadow: 0 0 10px rgba(156, 39, 176, 0.4); +} + .transcode-status.hidden { opacity: 0; pointer-events: none; diff --git a/public/index.html b/public/index.html index cfb728c..65246f6 100644 --- a/public/index.html +++ b/public/index.html @@ -587,6 +587,41 @@ +
+

Upscaling

+
+ +
+ Enable Upscaling + Upscale lower-resolution content to a higher resolution during transcoding. +
+
+ + +
+

Stream Processing

diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 751c8e2..2bc4d6f 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -814,16 +814,17 @@ class VideoPlayer { } } - if (info.needsTranscode) { - // Incompatible audio (AC3/EAC3/DTS) - use transcode session - console.log('[Player] Auto: Using HLS transcode session'); + if (info.needsTranscode || this.settings.upscaleEnabled) { + // Incompatible audio (AC3/EAC3/DTS) or Upscaling enabled - use transcode session + console.log(`[Player] Auto: Using HLS transcode session (${this.settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`); // Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only) - // If video is hevc/other, we need full transcode - const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode'; - const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)'; + // BUT: If upscaling is enabled, we MUST encode. + const videoMode = (info.video && info.video.includes('h264') && !this.settings.upscaleEnabled) ? 'copy' : 'encode'; + const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'); + const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding'; - this.updateTranscodeStatus('transcoding', statusText); + this.updateTranscodeStatus(statusMode, statusText); const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode, videoCodec: info.video, @@ -863,10 +864,12 @@ class VideoPlayer { } } - // CHECK: Force Video Transcode (Full) - if (this.settings.forceVideoTranscode) { - console.log('[Player] Force Video Transcode enabled. Starting session (encode)...'); - this.updateTranscodeStatus('transcoding', 'Transcoding (Video)'); + // CHECK: Force Video Transcode (Full) or Upscaling + if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) { + const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'; + const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding'; + console.log(`[Player] ${statusText} enabled. Starting session (encode)...`); + this.updateTranscodeStatus(statusMode, statusText); const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' }); this.currentUrl = playlistUrl; diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js index ea6045b..a47b5d7 100644 --- a/public/js/pages/Settings.js +++ b/public/js/pages/Settings.js @@ -163,6 +163,44 @@ class SettingsPage { }); } + // Upscaling Settings + const upscaleEnabledToggle = document.getElementById('setting-upscale-enabled'); + const upscaleMethodSelect = document.getElementById('setting-upscale-method'); + const upscaleTargetSelect = document.getElementById('setting-upscale-target'); + const upscaleMethodContainer = document.getElementById('upscale-method-container'); + const upscaleTargetContainer = document.getElementById('upscale-target-container'); + + // Helper to toggle upscale options visibility + const toggleUpscaleOptions = (enabled) => { + if (upscaleMethodContainer) upscaleMethodContainer.style.display = enabled ? 'flex' : 'none'; + if (upscaleTargetContainer) upscaleTargetContainer.style.display = enabled ? 'flex' : 'none'; + }; + + // Load upscaling settings + if (upscaleEnabledToggle) { + upscaleEnabledToggle.checked = s.upscaleEnabled || false; + toggleUpscaleOptions(upscaleEnabledToggle.checked); + } + if (upscaleMethodSelect) upscaleMethodSelect.value = s.upscaleMethod || 'hardware'; + if (upscaleTargetSelect) upscaleTargetSelect.value = s.upscaleTarget || '1080p'; + + // Upscaling event handlers + upscaleEnabledToggle?.addEventListener('change', () => { + this.app.player.settings.upscaleEnabled = upscaleEnabledToggle.checked; + this.app.player.saveSettings(); + toggleUpscaleOptions(upscaleEnabledToggle.checked); + }); + + upscaleMethodSelect?.addEventListener('change', () => { + this.app.player.settings.upscaleMethod = upscaleMethodSelect.value; + this.app.player.saveSettings(); + }); + + upscaleTargetSelect?.addEventListener('change', () => { + this.app.player.settings.upscaleTarget = upscaleTargetSelect.value; + this.app.player.saveSettings(); + }); + // Stream processing toggles forceProxyToggle?.addEventListener('change', () => { this.app.player.settings.forceProxy = forceProxyToggle.checked; diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js index 0218d27..6e8e25e 100644 --- a/public/js/pages/WatchPage.js +++ b/public/js/pages/WatchPage.js @@ -395,14 +395,16 @@ class WatchPage { this.currentStreamInfo = info; this.updateQualityBadge(); - if (info.needsTranscode) { - console.log('[WatchPage] Auto: Using HLS transcode session (incompatible audio/video)'); + if (info.needsTranscode || settings.upscaleEnabled) { + console.log(`[WatchPage] Auto: Using HLS transcode session (${settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`); - // Heuristic: If video is h264/compat, copy video. Usage: Audio fix. - const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode'; - const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)'; + // Heuristic: If video is h264/compat, copy video. Usage: Audio fix. + // BUT: If upscaling is enabled, we MUST encode. + const videoMode = (info.video && info.video.includes('h264') && !settings.upscaleEnabled) ? 'copy' : 'encode'; + const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'); + const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding'; - this.updateTranscodeStatus('transcoding', statusText); + this.updateTranscodeStatus(statusMode, statusText); const playlistUrl = await this.startTranscodeSession(url, { videoMode, seekOffset: this.resumeTime, // Ensure seekOffset is passed @@ -434,10 +436,12 @@ class WatchPage { } } - // Priority 1: Force Video Transcode (Full) - if (settings.forceVideoTranscode) { - console.log('[WatchPage] Force Video Transcode enabled. Starting session (encode)...'); - this.updateTranscodeStatus('transcoding', 'Transcoding (Video)'); + // Priority 1: Force Video Transcode (Full) or Upscaling + if (settings.forceVideoTranscode || settings.upscaleEnabled) { + const statusText = settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'; + const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding'; + console.log(`[WatchPage] ${statusText} enabled. Starting session (encode)...`); + this.updateTranscodeStatus(statusMode, statusText); const playlistUrl = await this.startTranscodeSession(url, { videoMode: 'encode', seekOffset: this.resumeTime diff --git a/server/db.js b/server/db.js index a7e71a5..127d9b8 100644 --- a/server/db.js +++ b/server/db.js @@ -79,7 +79,11 @@ function getDefaultSettings() { audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough // Probe cache settings probeCacheTTL: 300, // 5 minutes for URL probe cache - seriesProbeCacheDays: 7 // 7 days for series episode probe cache + seriesProbeCacheDays: 7, // 7 days for series episode probe cache + // Upscaling settings + upscaleEnabled: false, + upscaleMethod: 'hardware', // hardware | software + upscaleTarget: '1080p' // 1080p | 4k | 720p }; } diff --git a/server/routes/transcode.js b/server/routes/transcode.js index 08c736e..3f82ec9 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -48,6 +48,10 @@ router.post('/session', async (req, res) => { maxResolution: settings.maxResolution || '1080p', quality: settings.quality || 'medium', audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset + // Upscaling options + upscaleEnabled: settings.upscaleEnabled || false, + upscaleMethod: settings.upscaleMethod || 'hardware', + upscaleTarget: settings.upscaleTarget || '1080p', videoMode: videoMode, // 'copy' or 'encode' videoCodec: videoCodec, // 'h264', 'hevc', etc. audioCodec: audioCodec, // 'aac', 'ac3', etc. diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js index 8cc953b..d71e80f 100644 --- a/server/services/transcodeSession.js +++ b/server/services/transcodeSession.js @@ -72,6 +72,10 @@ class TranscodeSession extends EventEmitter { hwEncoder: options.hwEncoder || 'software', maxResolution: options.maxResolution || '1080p', quality: options.quality || 'medium', + // Upscaling options + upscaleEnabled: options.upscaleEnabled || false, + upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software' + upscaleTarget: options.upscaleTarget || '1080p', ...options }; } @@ -355,24 +359,65 @@ class TranscodeSession extends EventEmitter { } /** - * Get target height based on maxResolution setting + * Get target height based on maxResolution or upscaleTarget setting + * @param {boolean} forUpscale - If true, returns upscale target instead of max resolution */ - getTargetHeight() { + getTargetHeight(forUpscale = false) { const resolutionMap = { '4k': 2160, '1080p': 1080, '720p': 720, '480p': 480 }; + if (forUpscale && this.options.upscaleEnabled) { + return resolutionMap[this.options.upscaleTarget] || 1080; + } return resolutionMap[this.options.maxResolution] || 1080; } + /** + * Build scale filter string based on encoder and upscaling settings + * @param {string} encoder - The encoder being used + * @param {number} height - Target height + */ + buildScaleFilter(encoder, height) { + const useUpscale = this.options.upscaleEnabled; + const upscaleMethod = this.options.upscaleMethod || 'hardware'; + + // Log upscaling status + if (useUpscale) { + console.log(`[TranscodeSession ${this.id}] Upscaling: ${upscaleMethod} method to ${height}p`); + } + + // Hardware scaling filters (for both upscale and downscale) + if (upscaleMethod === 'hardware' || !useUpscale) { + switch (encoder) { + case 'nvenc': + // NVIDIA CUDA scaling with Lanczos for upscaling + return `scale_cuda=-2:${height}:interp_algo=lanczos`; + case 'vaapi': + return `scale_vaapi=w=-2:h=${height}:format=nv12`; + case 'qsv': + return `scale_qsv=w=-2:h=${height}`; + case 'amf': + // AMF uses CPU decode, so use software scale + return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`; + case 'software': + default: + return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`; + } + } + + // Software Lanczos scaling (high quality, slower) + return `scale=-2:${height}:flags=lanczos`; + } + /** * NVIDIA NVENC encoder arguments */ addNvencEncoderArgs(args, height, qp) { // Video filter for scaling on GPU - args.push('-vf', `scale_cuda=-2:${height}:interp_algo=lanczos`); + args.push('-vf', this.buildScaleFilter('nvenc', height)); // NVENC encoder with quality settings // Using portable options that work across FFmpeg builds @@ -390,7 +435,7 @@ class TranscodeSession extends EventEmitter { */ addAmfEncoderArgs(args, height, qp) { // CPU decoding + software scale + AMF encode - args.push('-vf', `scale=-2:${height}`); + args.push('-vf', this.buildScaleFilter('amf', height)); args.push( '-c:v', 'h264_amf', @@ -410,7 +455,7 @@ class TranscodeSession extends EventEmitter { // 1. scale_vaapi to resize on GPU // 2. Ensure output format is nv12 for maximum encoder compatibility // The format is handled automatically when using -hwaccel_output_format vaapi - args.push('-vf', `scale_vaapi=w=-2:h=${height}:format=nv12`); + args.push('-vf', this.buildScaleFilter('vaapi', height)); // VAAPI encoder with quality setting // Note: -global_quality is the portable way to set quality for VAAPI @@ -427,7 +472,7 @@ class TranscodeSession extends EventEmitter { */ addQsvEncoderArgs(args, height, qp) { // Scale on QSV - args.push('-vf', `scale_qsv=w=-2:h=${height}`); + args.push('-vf', this.buildScaleFilter('qsv', height)); args.push( '-c:v', 'h264_qsv', @@ -442,8 +487,8 @@ class TranscodeSession extends EventEmitter { * Software encoder arguments (fallback) */ addSoftwareEncoderArgs(args, height, crf) { - // Software scaling - args.push('-vf', `scale=-2:${height}`); + // Software scaling (use Lanczos for upscaling if enabled) + args.push('-vf', this.buildScaleFilter('software', height)); args.push( '-c:v', 'libx264',