Skip to content

Commit 7d5dbd9

Browse files
Merge pull request #64 from technomancer702/feature/upscaling
Feature/upscaling bug fixes
2 parents 877c6f8 + 056555c commit 7d5dbd9

File tree

10 files changed

+186
-36
lines changed

10 files changed

+186
-36
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ coverage/
88
.DS_Store
99
dist/
1010
release/
11-
11+
.vscode
1212
transcode-cache/

public/css/main.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4148,6 +4148,12 @@ kbd {
41484148
/* Green for Direct */
41494149
}
41504150

4151+
.transcode-status.upscaling {
4152+
background: rgba(156, 39, 176, 0.8);
4153+
/* Purple for Upscaling */
4154+
box-shadow: 0 0 10px rgba(156, 39, 176, 0.4);
4155+
}
4156+
41514157
.transcode-status.hidden {
41524158
opacity: 0;
41534159
pointer-events: none;

public/index.html

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,41 @@ <h3>Encoder Settings</h3>
587587
</div>
588588
</div>
589589

590+
<div class="settings-section">
591+
<h3>Upscaling</h3>
592+
<div class="setting-item">
593+
<label class="setting-toggle">
594+
<input type="checkbox" id="setting-upscale-enabled">
595+
<span class="toggle-slider"></span>
596+
</label>
597+
<div class="setting-info">
598+
<span class="setting-label">Enable Upscaling</span>
599+
<span class="setting-hint">Upscale lower-resolution content to a higher resolution during transcoding.</span>
600+
</div>
601+
</div>
602+
<div class="setting-item" id="upscale-method-container" style="display: none;">
603+
<div class="setting-info" style="flex: 1;">
604+
<span class="setting-label">Upscale Method</span>
605+
<span class="setting-hint">Hardware is faster; Software (Lanczos) is higher quality.</span>
606+
</div>
607+
<select id="setting-upscale-method" class="source-select" style="width: auto; min-width: 200px;">
608+
<option value="hardware">Hardware (GPU)</option>
609+
<option value="software">Software (Lanczos)</option>
610+
</select>
611+
</div>
612+
<div class="setting-item" id="upscale-target-container" style="display: none;">
613+
<div class="setting-info" style="flex: 1;">
614+
<span class="setting-label">Upscale Target</span>
615+
<span class="setting-hint">Target resolution for upscaled output.</span>
616+
</div>
617+
<select id="setting-upscale-target" class="source-select" style="width: auto; min-width: 200px;">
618+
<option value="720p">720p (HD)</option>
619+
<option value="1080p" selected>1080p (Full HD)</option>
620+
<option value="4k">4K (2160p)</option>
621+
</select>
622+
</div>
623+
</div>
624+
590625
<div class="settings-section">
591626
<h3>Stream Processing</h3>
592627
<div class="setting-item">

public/js/components/EpgGuide.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -920,8 +920,8 @@ class EpgGuide {
920920
);
921921
if (channel) {
922922
await window.app.channelList.selectChannel({ channelId: channel.id });
923-
// Switch to home page
924-
document.querySelector('[data-page="home"]').click();
923+
// Switch to live TV page
924+
document.querySelector('[data-page="live"]').click();
925925
}
926926
}
927927
}

public/js/components/VideoPlayer.js

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -814,16 +814,17 @@ class VideoPlayer {
814814
}
815815
}
816816

817-
if (info.needsTranscode) {
818-
// Incompatible audio (AC3/EAC3/DTS) - use transcode session
819-
console.log('[Player] Auto: Using HLS transcode session');
817+
if (info.needsTranscode || this.settings.upscaleEnabled) {
818+
// Incompatible audio (AC3/EAC3/DTS) or Upscaling enabled - use transcode session
819+
console.log(`[Player] Auto: Using HLS transcode session (${this.settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`);
820820

821821
// Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only)
822-
// If video is hevc/other, we need full transcode
823-
const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode';
824-
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)';
822+
// BUT: If upscaling is enabled, we MUST encode.
823+
const videoMode = (info.video && info.video.includes('h264') && !this.settings.upscaleEnabled) ? 'copy' : 'encode';
824+
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)');
825+
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
825826

826-
this.updateTranscodeStatus('transcoding', statusText);
827+
this.updateTranscodeStatus(statusMode, statusText);
827828
const playlistUrl = await this.startTranscodeSession(streamUrl, {
828829
videoMode,
829830
videoCodec: info.video,
@@ -863,10 +864,12 @@ class VideoPlayer {
863864
}
864865
}
865866

866-
// CHECK: Force Video Transcode (Full)
867-
if (this.settings.forceVideoTranscode) {
868-
console.log('[Player] Force Video Transcode enabled. Starting session (encode)...');
869-
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
867+
// CHECK: Force Video Transcode (Full) or Upscaling
868+
if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) {
869+
const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
870+
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
871+
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
872+
this.updateTranscodeStatus(statusMode, statusText);
870873
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
871874
this.currentUrl = playlistUrl;
872875

public/js/pages/Settings.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,44 @@ class SettingsPage {
163163
});
164164
}
165165

166+
// Upscaling Settings
167+
const upscaleEnabledToggle = document.getElementById('setting-upscale-enabled');
168+
const upscaleMethodSelect = document.getElementById('setting-upscale-method');
169+
const upscaleTargetSelect = document.getElementById('setting-upscale-target');
170+
const upscaleMethodContainer = document.getElementById('upscale-method-container');
171+
const upscaleTargetContainer = document.getElementById('upscale-target-container');
172+
173+
// Helper to toggle upscale options visibility
174+
const toggleUpscaleOptions = (enabled) => {
175+
if (upscaleMethodContainer) upscaleMethodContainer.style.display = enabled ? 'flex' : 'none';
176+
if (upscaleTargetContainer) upscaleTargetContainer.style.display = enabled ? 'flex' : 'none';
177+
};
178+
179+
// Load upscaling settings
180+
if (upscaleEnabledToggle) {
181+
upscaleEnabledToggle.checked = s.upscaleEnabled || false;
182+
toggleUpscaleOptions(upscaleEnabledToggle.checked);
183+
}
184+
if (upscaleMethodSelect) upscaleMethodSelect.value = s.upscaleMethod || 'hardware';
185+
if (upscaleTargetSelect) upscaleTargetSelect.value = s.upscaleTarget || '1080p';
186+
187+
// Upscaling event handlers
188+
upscaleEnabledToggle?.addEventListener('change', () => {
189+
this.app.player.settings.upscaleEnabled = upscaleEnabledToggle.checked;
190+
this.app.player.saveSettings();
191+
toggleUpscaleOptions(upscaleEnabledToggle.checked);
192+
});
193+
194+
upscaleMethodSelect?.addEventListener('change', () => {
195+
this.app.player.settings.upscaleMethod = upscaleMethodSelect.value;
196+
this.app.player.saveSettings();
197+
});
198+
199+
upscaleTargetSelect?.addEventListener('change', () => {
200+
this.app.player.settings.upscaleTarget = upscaleTargetSelect.value;
201+
this.app.player.saveSettings();
202+
});
203+
166204
// Stream processing toggles
167205
forceProxyToggle?.addEventListener('change', () => {
168206
this.app.player.settings.forceProxy = forceProxyToggle.checked;

public/js/pages/WatchPage.js

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -395,14 +395,16 @@ class WatchPage {
395395
this.currentStreamInfo = info;
396396
this.updateQualityBadge();
397397

398-
if (info.needsTranscode) {
399-
console.log('[WatchPage] Auto: Using HLS transcode session (incompatible audio/video)');
398+
if (info.needsTranscode || settings.upscaleEnabled) {
399+
console.log(`[WatchPage] Auto: Using HLS transcode session (${settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`);
400400

401-
// Heuristic: If video is h264/compat, copy video. Usage: Audio fix.
402-
const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode';
403-
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)';
401+
// Heuristic: If video is h264/compat, copy video. Usage: Audio fix.
402+
// BUT: If upscaling is enabled, we MUST encode.
403+
const videoMode = (info.video && info.video.includes('h264') && !settings.upscaleEnabled) ? 'copy' : 'encode';
404+
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)');
405+
const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding';
404406

405-
this.updateTranscodeStatus('transcoding', statusText);
407+
this.updateTranscodeStatus(statusMode, statusText);
406408
const playlistUrl = await this.startTranscodeSession(url, {
407409
videoMode,
408410
seekOffset: this.resumeTime, // Ensure seekOffset is passed
@@ -434,10 +436,12 @@ class WatchPage {
434436
}
435437
}
436438

437-
// Priority 1: Force Video Transcode (Full)
438-
if (settings.forceVideoTranscode) {
439-
console.log('[WatchPage] Force Video Transcode enabled. Starting session (encode)...');
440-
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
439+
// Priority 1: Force Video Transcode (Full) or Upscaling
440+
if (settings.forceVideoTranscode || settings.upscaleEnabled) {
441+
const statusText = settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
442+
const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding';
443+
console.log(`[WatchPage] ${statusText} enabled. Starting session (encode)...`);
444+
this.updateTranscodeStatus(statusMode, statusText);
441445
const playlistUrl = await this.startTranscodeSession(url, {
442446
videoMode: 'encode',
443447
seekOffset: this.resumeTime

server/db.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ function getDefaultSettings() {
7979
audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough
8080
// Probe cache settings
8181
probeCacheTTL: 300, // 5 minutes for URL probe cache
82-
seriesProbeCacheDays: 7 // 7 days for series episode probe cache
82+
seriesProbeCacheDays: 7, // 7 days for series episode probe cache
83+
// Upscaling settings
84+
upscaleEnabled: false,
85+
upscaleMethod: 'hardware', // hardware | software
86+
upscaleTarget: '1080p' // 1080p | 4k | 720p
8387
};
8488
}
8589

server/routes/transcode.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ router.post('/session', async (req, res) => {
4848
maxResolution: settings.maxResolution || '1080p',
4949
quality: settings.quality || 'medium',
5050
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
51+
// Upscaling options
52+
upscaleEnabled: settings.upscaleEnabled || false,
53+
upscaleMethod: settings.upscaleMethod || 'hardware',
54+
upscaleTarget: settings.upscaleTarget || '1080p',
5155
videoMode: videoMode, // 'copy' or 'encode'
5256
videoCodec: videoCodec, // 'h264', 'hevc', etc.
5357
audioCodec: audioCodec, // 'aac', 'ac3', etc.

server/services/transcodeSession.js

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ class TranscodeSession extends EventEmitter {
7272
hwEncoder: options.hwEncoder || 'software',
7373
maxResolution: options.maxResolution || '1080p',
7474
quality: options.quality || 'medium',
75+
// Upscaling options
76+
upscaleEnabled: options.upscaleEnabled || false,
77+
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
78+
upscaleTarget: options.upscaleTarget || '1080p',
7579
...options
7680
};
7781
}
@@ -355,7 +359,9 @@ class TranscodeSession extends EventEmitter {
355359
}
356360

357361
/**
358-
* Get target height based on maxResolution setting
362+
* Get target height based on maxResolution or upscaleTarget setting
363+
* When upscaling is enabled, uses the upscaleTarget resolution.
364+
* Otherwise, uses maxResolution to cap the output.
359365
*/
360366
getTargetHeight() {
361367
const resolutionMap = {
@@ -364,15 +370,61 @@ class TranscodeSession extends EventEmitter {
364370
'720p': 720,
365371
'480p': 480
366372
};
373+
374+
// When upscaling is enabled, use the upscale target resolution
375+
if (this.options.upscaleEnabled) {
376+
const target = resolutionMap[this.options.upscaleTarget] || 1080;
377+
console.log(`[TranscodeSession ${this.id}] Upscale target height: ${target}p`);
378+
return target;
379+
}
380+
381+
// Otherwise, use max resolution as the cap
367382
return resolutionMap[this.options.maxResolution] || 1080;
368383
}
369384

385+
/**
386+
* Build scale filter string based on encoder and upscaling settings
387+
* @param {string} encoder - The encoder being used
388+
* @param {number} height - Target height
389+
*/
390+
buildScaleFilter(encoder, height) {
391+
const useUpscale = this.options.upscaleEnabled;
392+
const upscaleMethod = this.options.upscaleMethod || 'hardware';
393+
394+
// Log upscaling status
395+
if (useUpscale) {
396+
console.log(`[TranscodeSession ${this.id}] Upscaling: ${upscaleMethod} method to ${height}p`);
397+
}
398+
399+
// Hardware scaling filters (for both upscale and downscale)
400+
if (upscaleMethod === 'hardware' || !useUpscale) {
401+
switch (encoder) {
402+
case 'nvenc':
403+
// NVIDIA CUDA scaling with Lanczos for upscaling
404+
return `scale_cuda=-2:${height}:interp_algo=lanczos`;
405+
case 'vaapi':
406+
return `scale_vaapi=w=-2:h=${height}:format=nv12`;
407+
case 'qsv':
408+
return `scale_qsv=w=-2:h=${height}`;
409+
case 'amf':
410+
// AMF uses CPU decode, so use software scale
411+
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
412+
case 'software':
413+
default:
414+
return useUpscale ? `scale=-2:${height}:flags=lanczos` : `scale=-2:${height}`;
415+
}
416+
}
417+
418+
// Software Lanczos scaling (high quality, slower)
419+
return `scale=-2:${height}:flags=lanczos`;
420+
}
421+
370422
/**
371423
* NVIDIA NVENC encoder arguments
372424
*/
373425
addNvencEncoderArgs(args, height, qp) {
374426
// Video filter for scaling on GPU
375-
args.push('-vf', `scale_cuda=-2:${height}:interp_algo=lanczos`);
427+
args.push('-vf', this.buildScaleFilter('nvenc', height));
376428

377429
// NVENC encoder with quality settings
378430
// Using portable options that work across FFmpeg builds
@@ -381,7 +433,8 @@ class TranscodeSession extends EventEmitter {
381433
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
382434
'-rc', 'constqp', // Constant QP mode
383435
'-qp', String(qp),
384-
'-bf', '3' // B-frames for better compression
436+
'-bf', '3', // B-frames for better compression
437+
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
385438
);
386439
}
387440

@@ -390,15 +443,16 @@ class TranscodeSession extends EventEmitter {
390443
*/
391444
addAmfEncoderArgs(args, height, qp) {
392445
// CPU decoding + software scale + AMF encode
393-
args.push('-vf', `scale=-2:${height}`);
446+
args.push('-vf', this.buildScaleFilter('amf', height));
394447

395448
args.push(
396449
'-c:v', 'h264_amf',
397450
'-quality', 'quality', // Quality preset
398451
'-rc', 'cqp', // Constant QP
399452
'-qp_i', String(qp),
400453
'-qp_p', String(qp + 2),
401-
'-qp_b', String(qp + 4)
454+
'-qp_b', String(qp + 4),
455+
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
402456
);
403457
}
404458

@@ -410,15 +464,16 @@ class TranscodeSession extends EventEmitter {
410464
// 1. scale_vaapi to resize on GPU
411465
// 2. Ensure output format is nv12 for maximum encoder compatibility
412466
// The format is handled automatically when using -hwaccel_output_format vaapi
413-
args.push('-vf', `scale_vaapi=w=-2:h=${height}:format=nv12`);
467+
args.push('-vf', this.buildScaleFilter('vaapi', height));
414468

415469
// VAAPI encoder with quality setting
416470
// Note: -global_quality is the portable way to set quality for VAAPI
417471
args.push(
418472
'-c:v', 'h264_vaapi',
419473
'-profile:v', 'main', // Use main profile for compatibility
420474
'-global_quality', String(qp),
421-
'-bf', '3'
475+
'-bf', '3',
476+
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
422477
);
423478
}
424479

@@ -427,23 +482,24 @@ class TranscodeSession extends EventEmitter {
427482
*/
428483
addQsvEncoderArgs(args, height, qp) {
429484
// Scale on QSV
430-
args.push('-vf', `scale_qsv=w=-2:h=${height}`);
485+
args.push('-vf', this.buildScaleFilter('qsv', height));
431486

432487
args.push(
433488
'-c:v', 'h264_qsv',
434489
'-preset', 'medium',
435490
'-global_quality', String(qp),
436491
'-look_ahead', '1',
437-
'-look_ahead_depth', '40'
492+
'-look_ahead_depth', '40',
493+
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
438494
);
439495
}
440496

441497
/**
442498
* Software encoder arguments (fallback)
443499
*/
444500
addSoftwareEncoderArgs(args, height, crf) {
445-
// Software scaling
446-
args.push('-vf', `scale=-2:${height}`);
501+
// Software scaling (use Lanczos for upscaling if enabled)
502+
args.push('-vf', this.buildScaleFilter('software', height));
447503

448504
args.push(
449505
'-c:v', 'libx264',

0 commit comments

Comments
 (0)