feat: add Plex-style adaptive transcoding with bitrate limits and hardware fallback

This commit is contained in:
blomios
2026-09-29 23:31:05 +02:00
parent 2912384257
commit d018ac078c
7 changed files with 258 additions and 66 deletions

View File

@ -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

View File

@ -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
});
}

View File

@ -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;

View File

@ -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
}));
}