feat: add Plex-style adaptive transcoding with bitrate limits and hardware fallback
This commit is contained in:
@ -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
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user