diff --git a/public/index.html b/public/index.html index dd74211..5f89d4a 100644 --- a/public/index.html +++ b/public/index.html @@ -539,7 +539,7 @@

Encoder Settings

- Hardware Encoder + Transcoding Device Select which GPU encoder to use for transcoding. Auto will use the best available.
@@ -554,7 +554,7 @@
- Max Resolution + Maximum Transcoded Resolution Limit transcoded output resolution. Lower = faster, more compatible.
@@ -567,7 +567,7 @@
- Quality Preset + Encoding Quality Higher quality uses more bandwidth and CPU/GPU.
@@ -617,7 +617,7 @@
+
+
+ Maximum Streaming Bandwidth + The bandwidth limit is evaluated when playback starts. Quality does not change automatically during playback. +
+ +
+ + +
+

Stream Processing

- Force Audio Transcode + Always Convert Audio Transcode audio to AAC for browser compatibility. Enable if you get video but no audio (fixes Dolby/AC3/EAC3).
@@ -658,7 +676,7 @@
- Force Video Transcode + Always Convert Video Force full video transcoding (burn-in subtitles, incompatible video). Uses HW encoder. @@ -670,14 +688,14 @@
- Force Remux + Always Remux Streams Remux streams to MP4 container. Enable for raw .ts streams from IPTV middleware like m3u-editor or dispatcharr.
- Stream Output Format + Stream Container Container format for Xtream streams. Try TS if you experience buffering issues.
@@ -686,6 +704,15 @@
+
+ +
+ Use CPU if hardware transcoding fails +
+
@@ -1125,4 +1152,4 @@ - \ No newline at end of file + diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 5e81fce..c62d9f0 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -53,6 +53,9 @@ class VideoPlayer { forceTranscode: false, forceRemux: false, autoTranscode: true, + maxDirectBitrateKbps: 0, + targetTranscodeBitrateKbps: 6000, + allowHardwareFallback: true, streamFormat: 'm3u8', epgRefreshInterval: '24' }; @@ -813,6 +816,7 @@ class VideoPlayer { * Start a HLS transcode session */ async startTranscodeSession(url, options = {}) { + this.currentTranscodeSession = null; try { console.log('[Player] Starting HLS transcode session...', options); const res = await fetch('/api/transcode/session', { @@ -823,6 +827,7 @@ class VideoPlayer { if (!res.ok) throw new Error('Failed to start session'); const session = await res.json(); this.currentSessionId = session.sessionId; + this.currentTranscodeSession = session; return session.playlistUrl; } catch (err) { console.error('[Player] Session start failed:', err); @@ -871,9 +876,14 @@ class VideoPlayer { // Determine if HLS or direct stream this.currentUrl = streamUrl; - // CHECK: Auto Transcode (Smart) - probe first, then decide - if (this.settings.autoTranscode) { + // Forced playback modes take priority over automatic selection below. + const hasForcedPlaybackMode = this.settings.forceVideoTranscode || + this.settings.forceTranscode || this.settings.forceRemux; + + // Automatic playback: probe once, then choose transcode, remux, or direct play. + if (this.settings.autoTranscode && !hasForcedPlaybackMode) { console.log('[Player] Auto Transcode enabled. Probing stream...'); + this.updateTranscodeStatus('direct', 'Analyzing…'); try { const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); const info = await probeRes.json(); @@ -905,14 +915,23 @@ class VideoPlayer { } } - if (info.needsTranscode || this.settings.upscaleEnabled) { + const maxDirectBitrateKbps = Number(this.settings.maxDirectBitrateKbps) || 0; + const overallBitrateKbps = Number(info.overallBitrateKbps) || 0; + const exceedsBandwidthLimit = maxDirectBitrateKbps > 0 && + overallBitrateKbps > maxDirectBitrateKbps; + const requiresTranscode = info.needsTranscode || this.settings.upscaleEnabled || + (!info.needsRemux && exceedsBandwidthLimit); + + if (requiresTranscode) { // 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) // 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 videoMode = (info.video && info.video.includes('h264') && + !this.settings.upscaleEnabled && !exceedsBandwidthLimit) ? 'copy' : 'encode'; + const statusText = videoMode === 'copy' ? 'Audio Transcode' : + (this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode'); const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding'; this.updateTranscodeStatus(statusMode, statusText); @@ -920,8 +939,18 @@ class VideoPlayer { videoMode, videoCodec: info.video, audioCodec: info.audio, - audioChannels: info.audioChannels + audioChannels: info.audioChannels, + targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps || + this.settings.maxDirectBitrateKbps || 6000, + allowHardwareFallback: this.settings.allowHardwareFallback !== false }); + if (this.currentTranscodeSession?.hardwareFallbackOccurred) { + this.updateTranscodeStatus('transcoding', 'CPU Fallback'); + } + if (exceedsBandwidthLimit) { + this.updateQualityBadge(this.settings.targetTranscodeBitrateKbps || + this.settings.maxDirectBitrateKbps || 6000); + } this.currentUrl = playlistUrl; // Update currentUrl for HLS reload this.playHls(playlistUrl); @@ -934,7 +963,7 @@ class VideoPlayer { } else if (info.needsRemux) { // Raw .ts container - use remux console.log('[Player] Auto: Using remux (.ts container)'); - this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); + this.updateTranscodeStatus('remuxing', 'Remux'); const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`; this.currentUrl = remuxUrl; this.video.src = remuxUrl; @@ -949,6 +978,7 @@ class VideoPlayer { } // Compatible - fall through to normal HLS.js path console.log('[Player] Auto: Using HLS.js (compatible)'); + this.updateTranscodeStatus('direct', 'Direct Play'); } catch (err) { console.warn('[Player] Probe failed, using normal playback:', err.message); // Continue with normal playback on probe failure @@ -957,11 +987,19 @@ class VideoPlayer { // CHECK: Force Video Transcode (Full) or Upscaling if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) { - const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)'; + const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode'; 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' }); + const playlistUrl = await this.startTranscodeSession(streamUrl, { + videoMode: 'encode', + targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps || + this.settings.maxDirectBitrateKbps || 6000, + allowHardwareFallback: this.settings.allowHardwareFallback !== false + }); + if (this.currentTranscodeSession?.hardwareFallbackOccurred) { + this.updateTranscodeStatus('transcoding', 'CPU Fallback'); + } this.currentUrl = playlistUrl; // Load HLS @@ -1002,7 +1040,7 @@ class VideoPlayer { // CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting if (this.settings.forceTranscode) { console.log('[Player] Force Audio Transcode enabled. Starting session (copy)...'); - this.updateTranscodeStatus('transcoding', 'Transcoding (Audio)'); + this.updateTranscodeStatus('transcoding', 'Audio Transcode'); // Probe to get video codec for HEVC tag handling let videoCodec = 'unknown'; @@ -1012,7 +1050,11 @@ class VideoPlayer { videoCodec = info.video; } catch (e) { console.warn('Probe failed for force audio, assuming h264'); } - const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec }); + const playlistUrl = await this.startTranscodeSession(streamUrl, { + videoMode: 'copy', + videoCodec, + allowHardwareFallback: this.settings.allowHardwareFallback !== false + }); this.currentUrl = playlistUrl; console.log('[Player] Playing transcoded HLS stream:', playlistUrl); @@ -1053,7 +1095,7 @@ class VideoPlayer { if (this.settings.forceRemux && (isRawTs || isExtensionless)) { console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...'); console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)'); - this.updateTranscodeStatus('remuxing', 'Remux (Force)'); + this.updateTranscodeStatus('remuxing', 'Remux'); const remuxUrl = this.getRemuxUrl(streamUrl); this.video.src = remuxUrl; this.video.play().catch(e => { @@ -1082,7 +1124,7 @@ class VideoPlayer { // Priority 1: Use HLS.js for HLS streams on browsers that support it if (looksLikeHls && Hls.isSupported()) { - this.updateTranscodeStatus('direct', 'Direct HLS'); + this.updateTranscodeStatus('direct', 'Direct Play'); // Use playHls helper logic here (or extract it) // For now, let's just use existing logic but wrapped/modularized if possible? @@ -1146,7 +1188,7 @@ class VideoPlayer { } else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' || this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') { // Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work) - this.updateTranscodeStatus('direct', 'Direct Native'); + this.updateTranscodeStatus('direct', 'Direct Play'); this.video.src = finalUrl; this.video.play().catch(e => { if (e.name === 'AbortError') return; // Ignore interruption by new load @@ -1247,11 +1289,15 @@ class VideoPlayer { /** * Update quality badge display */ - updateQualityBadge() { + updateQualityBadge(targetBitrateKbps = null) { const badge = document.getElementById('player-quality-badge'); if (!badge) return; - if (this.currentStreamInfo?.height > 0) { + if (targetBitrateKbps) { + const targetMbps = targetBitrateKbps / 1000; + badge.textContent = `${Number.isInteger(targetMbps) ? targetMbps : targetMbps.toFixed(1)} Mbps`; + badge.classList.remove('hidden'); + } else if (this.currentStreamInfo?.height > 0) { badge.textContent = this.getQualityLabel(this.currentStreamInfo.height); badge.classList.remove('hidden'); } else { diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js index a47b5d7..a1208b4 100644 --- a/public/js/pages/Settings.js +++ b/public/js/pages/Settings.js @@ -107,6 +107,8 @@ class SettingsPage { const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc'); const forceRemuxToggle = document.getElementById('setting-force-remux-tc'); const streamFormatSelect = document.getElementById('setting-stream-format-tc'); + const maxDirectBitrateSelect = document.getElementById('setting-max-direct-bitrate'); + const allowHardwareFallbackToggle = document.getElementById('setting-allow-hardware-fallback'); // User-Agent (Transcoding tab versions) const userAgentSelect = document.getElementById('setting-user-agent-tc'); @@ -131,6 +133,8 @@ class SettingsPage { if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true; if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false; if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8'; + if (maxDirectBitrateSelect) maxDirectBitrateSelect.value = String(s.maxDirectBitrateKbps || 0); + if (allowHardwareFallbackToggle) allowHardwareFallbackToggle.checked = s.allowHardwareFallback !== false; if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome'; if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || ''; if (customUaContainer) { @@ -232,6 +236,16 @@ class SettingsPage { this.app.player.saveSettings(); }); + maxDirectBitrateSelect?.addEventListener('change', () => { + this.app.player.settings.maxDirectBitrateKbps = parseInt(maxDirectBitrateSelect.value, 10) || 0; + this.app.player.saveSettings(); + }); + + allowHardwareFallbackToggle?.addEventListener('change', () => { + this.app.player.settings.allowHardwareFallback = allowHardwareFallbackToggle.checked; + this.app.player.saveSettings(); + }); + // User-Agent handlers const toggleCustomInput = () => { if (customUaContainer) { diff --git a/server/db.js b/server/db.js index 127d9b8..0291161 100644 --- a/server/db.js +++ b/server/db.js @@ -76,6 +76,9 @@ function getDefaultSettings() { hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software maxResolution: '1080p', // 4k | 1080p | 720p | 480p quality: 'medium', // high | medium | low + maxDirectBitrateKbps: 0, // 0 = unlimited + targetTranscodeBitrateKbps: 6000, + allowHardwareFallback: true, audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough // Probe cache settings probeCacheTTL: 300, // 5 minutes for URL probe cache diff --git a/server/routes/probe.js b/server/routes/probe.js index 751ef43..2baf2d5 100644 --- a/server/routes/probe.js +++ b/server/routes/probe.js @@ -87,6 +87,22 @@ function analyzeProbeResult(probeResult, url) { const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown'; const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown'; const container = format.format_name?.toLowerCase() || 'unknown'; + const toPositiveNumber = (value) => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + }; + const toKbps = (value) => { + const bitrate = toPositiveNumber(value); + return bitrate === null ? null : Math.round(bitrate / 1000); + }; + const durationSeconds = toPositiveNumber(format.duration) ?? toPositiveNumber(videoStream?.duration) ?? toPositiveNumber(audioStream?.duration); + const videoBitrateKbps = toKbps(videoStream?.bit_rate); + const audioBitrateKbps = toKbps(audioStream?.bit_rate); + const overallBitrateKbps = toKbps(format.bit_rate); + const probeConfidence = durationSeconds !== null && videoBitrateKbps !== null && + (!audioStream || audioBitrateKbps !== null) && overallBitrateKbps !== null + ? 'complete' + : 'partial'; // Check codec compatibility const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c)); @@ -131,6 +147,11 @@ function analyzeProbeResult(probeResult, url) { width: videoStream?.width || 0, height: videoStream?.height || 0, audioChannels: audioStream?.channels || 0, // For Smart Audio Copy + durationSeconds, + videoBitrateKbps, + audioBitrateKbps, + overallBitrateKbps, + probeConfidence, container: container, compatible: compatible, needsRemux: needsRemux, @@ -157,7 +178,12 @@ router.get('/', async (req, res) => { container: 'unknown', compatible: false, needsRemux: false, - needsTranscode: true + needsTranscode: true, + durationSeconds: null, + videoBitrateKbps: null, + audioBitrateKbps: null, + overallBitrateKbps: null, + probeConfidence: 'partial' }); } @@ -193,6 +219,11 @@ router.get('/', async (req, res) => { compatible: false, needsRemux: false, needsTranscode: true, + durationSeconds: null, + videoBitrateKbps: null, + audioBitrateKbps: null, + overallBitrateKbps: null, + probeConfidence: 'partial', error: err.message }); } diff --git a/server/routes/transcode.js b/server/routes/transcode.js index 3f82ec9..b11ef57 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -29,7 +29,10 @@ transcodeSession.startCleanupInterval(); * Body: { url: string, seekOffset?: number } */ router.post('/session', async (req, res) => { - const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body; + const { + url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels, + targetVideoBitrateKbps, targetAudioBitrateKbps, allowHardwareFallback + } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); @@ -47,6 +50,9 @@ router.post('/session', async (req, res) => { hwEncoder: settings.hwEncoder || 'software', maxResolution: settings.maxResolution || '1080p', quality: settings.quality || 'medium', + targetVideoBitrateKbps: targetVideoBitrateKbps ?? settings.targetTranscodeBitrateKbps ?? 6000, + targetAudioBitrateKbps: targetAudioBitrateKbps ?? 192, + allowHardwareFallback: allowHardwareFallback ?? settings.allowHardwareFallback ?? true, audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset // Upscaling options upscaleEnabled: settings.upscaleEnabled || false, @@ -71,7 +77,10 @@ router.post('/session', async (req, res) => { res.json({ sessionId: session.id, playlistUrl: `/api/transcode/${session.id}/stream.m3u8`, - status: session.status + status: session.status, + requestedEncoder: session.requestedEncoder, + effectiveEncoder: session.effectiveEncoder, + hardwareFallbackOccurred: session.hardwareFallbackOccurred }); } catch (err) { @@ -270,4 +279,3 @@ router.get('/', async (req, res) => { }); module.exports = router; - diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js index 4d6c2f7..c473476 100644 --- a/server/services/transcodeSession.js +++ b/server/services/transcodeSession.js @@ -63,20 +63,28 @@ class TranscodeSession extends EventEmitter { this.segments = new Map(); // segment index -> { ready: boolean, path: string } this.status = 'pending'; // pending | starting | running | stopped | error this.error = null; + this.requestedEncoder = options.hwEncoder || 'software'; + this.effectiveEncoder = null; + this.hardwareFallbackOccurred = false; + this.playlistReady = false; + this.processGeneration = 0; this.startTime = Date.now(); this.lastAccess = Date.now(); this.options = { + ...options, ffmpegPath: options.ffmpegPath || 'ffmpeg', userAgent: options.userAgent || 'Mozilla/5.0', seekOffset: options.seekOffset || 0, hwEncoder: options.hwEncoder || 'software', maxResolution: options.maxResolution || '1080p', quality: options.quality || 'medium', + targetVideoBitrateKbps: this.normalizeBitrate(options.targetVideoBitrateKbps, 6000), + targetAudioBitrateKbps: this.normalizeBitrate(options.targetAudioBitrateKbps, 192), + allowHardwareFallback: options.allowHardwareFallback !== false, // Upscaling options upscaleEnabled: options.upscaleEnabled || false, upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software' - upscaleTarget: options.upscaleTarget || '1080p', - ...options + upscaleTarget: options.upscaleTarget || '1080p' }; } @@ -100,27 +108,62 @@ class TranscodeSession extends EventEmitter { throw err; } - // Build FFmpeg arguments for HLS output - const args = this.buildFFmpegArgs(); + try { + const encoder = this.resolveEncoder(); + this.requestedEncoder = this.options.hwEncoder || 'software'; + this.startProcess(encoder); + // Save session metadata + await this.persist(); + + } catch (err) { + this.status = 'error'; + this.error = err.message; + throw err; + } + } + + normalizeBitrate(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; + } + + resolveEncoder() { + let encoder = this.options.hwEncoder || 'software'; + if (encoder === 'auto') { + const hwCaps = hwDetect.getCapabilities(); + encoder = hwCaps?.recommended || 'software'; + console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`); + } + return encoder; + } + + startProcess(encoder) { + const args = this.buildFFmpegArgs(encoder); + const generation = ++this.processGeneration; + this.effectiveEncoder = encoder; + this.status = 'starting'; + this.error = null; + console.log(`[TranscodeSession ${this.id}] Starting with requested encoder=${this.requestedEncoder}, effective encoder=${encoder}`); console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`); try { - this.process = spawn(this.options.ffmpegPath, args, { + const process = spawn(this.options.ffmpegPath, args, { cwd: this.dir, windowsHide: true }); + this.process = process; this.status = 'running'; // Handle stdout (should be empty for file output) - this.process.stdout.on('data', (data) => { + process.stdout.on('data', (data) => { console.log(`[TranscodeSession ${this.id}] stdout: ${data}`); }); // Handle stderr (FFmpeg progress/errors) let stderrBuffer = ''; - this.process.stderr.on('data', (data) => { + process.stderr.on('data', (data) => { stderrBuffer += data.toString(); // Log periodically to avoid spam const lines = stderrBuffer.split('\n'); @@ -135,51 +178,62 @@ class TranscodeSession extends EventEmitter { }); // Handle process exit - this.process.on('exit', (code) => { + process.on('exit', (code) => { + if (generation !== this.processGeneration) return; if (code === 0 || code === null) { console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`); this.status = 'stopped'; } else if (code !== 255) { // 255 is often from SIGKILL - console.error(`[TranscodeSession ${this.id}] FFmpeg exited with code ${code}`); + const error = `FFmpeg exited with code ${code}`; + if (this.tryHardwareFallback(error, encoder)) return; + console.error(`[TranscodeSession ${this.id}] ${error}`); this.status = 'error'; - this.error = `FFmpeg exited with code ${code}`; + this.error = error; } this.process = null; this.emit('exit', code); }); // Handle spawn errors - this.process.on('error', (err) => { + process.on('error', (err) => { + if (generation !== this.processGeneration) return; + if (this.tryHardwareFallback(err.message, encoder)) return; console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err); this.status = 'error'; this.error = err.message; - this.emit('error', err); + if (this.listenerCount('error') > 0) { + this.emit('error', err); + } }); - // Save session metadata - await this.persist(); - } catch (err) { - this.status = 'error'; - this.error = err.message; + if (this.tryHardwareFallback(err.message, encoder)) return; throw err; } } + tryHardwareFallback(initialError, encoder) { + const canFallback = this.options.videoMode !== 'copy' && encoder !== 'software' && + this.options.allowHardwareFallback && !this.hardwareFallbackOccurred && !this.playlistReady; + if (!canFallback) return false; + + this.hardwareFallbackOccurred = true; + console.error(`[TranscodeSession ${this.id}] Hardware encoder ${encoder} failed during startup: ${initialError}`); + console.log(`[TranscodeSession ${this.id}] Retrying once with software encoder`); + this.startProcess('software'); + this.persist().catch(err => console.error(`[TranscodeSession ${this.id}] Failed to persist fallback state:`, err.message)); + return true; + } + /** * Build FFmpeg arguments for HLS output with optional GPU encoding */ - buildFFmpegArgs() { + buildFFmpegArgs(resolvedEncoder = null) { const segmentPattern = path.join(this.dir, 'seg%04d.m4s'); const videoMode = this.options.videoMode || 'encode'; // Resolve 'auto' encoder to detected hardware, fallback to software - let encoder = this.options.hwEncoder || 'software'; - if (encoder === 'auto') { - const hwCaps = hwDetect.getCapabilities(); - encoder = hwCaps?.recommended || 'software'; - console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`); - } + const encoder = resolvedEncoder || this.resolveEncoder(); const args = [ '-hide_banner', @@ -264,7 +318,7 @@ class TranscodeSession extends EventEmitter { args.push( '-c:a', 'aac', '-ar', '48000', - '-b:a', '192k', + '-b:a', `${this.options.targetAudioBitrateKbps}k`, '-af', `${panFilter},aresample=async=1` ); } @@ -336,6 +390,7 @@ class TranscodeSession extends EventEmitter { 'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 } }; const qp = qualityPresets[quality] || qualityPresets.medium; + const bitrate = this.options.targetVideoBitrateKbps; switch (encoder) { case 'nvenc': @@ -356,6 +411,7 @@ class TranscodeSession extends EventEmitter { this.addSoftwareEncoderArgs(args, resolution, qp.software); break; } + args.push('-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`); } /** @@ -432,8 +488,8 @@ class TranscodeSession extends EventEmitter { args.push( '-c:v', 'h264_nvenc', '-preset', 'p4', // Balanced preset (p1=fastest, p7=best) - '-rc', 'constqp', // Constant QP mode - '-qp', String(qp), + '-rc', 'vbr', // Bitrate-constrained variable bitrate + '-cq', String(qp), // Quality target within bitrate constraint '-bf', '3' // B-frames for better compression ); } @@ -448,10 +504,7 @@ class TranscodeSession extends EventEmitter { args.push( '-c:v', 'h264_amf', '-quality', 'quality', // Quality preset - '-rc', 'cqp', // Constant QP - '-qp_i', String(qp), - '-qp_p', String(qp + 2), - '-qp_b', String(qp + 4), + '-rc', 'vbr_peak', // Bitrate-constrained variable bitrate '-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility ); } @@ -556,6 +609,7 @@ class TranscodeSession extends EventEmitter { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { if (await this.isPlaylistReady()) { + this.playlistReady = true; return true; } await new Promise(resolve => setTimeout(resolve, 200)); @@ -600,6 +654,9 @@ class TranscodeSession extends EventEmitter { startTime: this.startTime, lastAccess: this.lastAccess, options: this.options, + requestedEncoder: this.requestedEncoder, + effectiveEncoder: this.effectiveEncoder, + hardwareFallbackOccurred: this.hardwareFallbackOccurred, seekOffset: this.options.seekOffset }; const metaPath = path.join(this.dir, 'session.json'); @@ -621,6 +678,9 @@ class TranscodeSession extends EventEmitter { session.startTime = metadata.startTime; session.lastAccess = metadata.lastAccess; session.status = 'stopped'; // Not running after restart + session.requestedEncoder = metadata.requestedEncoder || session.requestedEncoder; + session.effectiveEncoder = metadata.effectiveEncoder || null; + session.hardwareFallbackOccurred = metadata.hardwareFallbackOccurred || false; return session; } catch (err) { console.error(`Failed to restore session from ${sessionDir}:`, err.message); @@ -753,7 +813,10 @@ function getAllSessions() { status: s.status, startTime: s.startTime, lastAccess: s.lastAccess, - idleMs: Date.now() - s.lastAccess + idleMs: Date.now() - s.lastAccess, + requestedEncoder: s.requestedEncoder, + effectiveEncoder: s.effectiveEncoder, + hardwareFallbackOccurred: s.hardwareFallbackOccurred })); }