feat: Implement hardware-accelerated HLS transcoding with granular controls, smart stereo downmixing, and robust support for MKV and 10-bit HEVC playback.
This commit is contained in:
+2
-1
@@ -63,7 +63,8 @@ function getDefaultSettings() {
|
||||
lastVolume: 80,
|
||||
autoPlayNextEpisode: false,
|
||||
forceProxy: false,
|
||||
forceTranscode: false,
|
||||
forceTranscode: false, // Force Audio Transcode
|
||||
forceVideoTranscode: false, // Force Video Transcode
|
||||
forceRemux: false,
|
||||
autoTranscode: true,
|
||||
streamFormat: 'm3u8',
|
||||
|
||||
@@ -112,11 +112,15 @@ function analyzeProbeResult(probeResult, url) {
|
||||
}));
|
||||
|
||||
// Determine what processing is needed
|
||||
// 1. Incompatible audio -> Transcode (highest priority)
|
||||
const needsTranscode = !audioOk;
|
||||
// 4. MKV files often cause OOM/decoding issues in browser fMP4 remux,
|
||||
// so we force them to "needsTranscode" which uses HLS (more robust).
|
||||
// The frontend will still use "copy" mode if codecs are compatible.
|
||||
const isMkv = container.includes('matroska') || container.includes('webm') || url.endsWith('.mkv');
|
||||
|
||||
// 2. Compatible audio/video but incompatible container -> Remux
|
||||
// This catches raw TS, MKV, AVI, etc. that have H.264/AAC but wrong container
|
||||
// 1. Incompatible audio/video OR MKV -> Transcode (or HLS Copy)
|
||||
const needsTranscode = !audioOk || !videoOk || isMkv;
|
||||
|
||||
// 2. Compatible audio/video but incompatible container (non-MKV) -> Remux (fMP4 pipe)
|
||||
const needsRemux = !needsTranscode && (!containerOk || isRawTs);
|
||||
|
||||
const compatible = !needsTranscode && !needsRemux;
|
||||
|
||||
@@ -34,9 +34,10 @@ router.get('/', async (req, res) => {
|
||||
'-hide_banner',
|
||||
'-loglevel', 'warning',
|
||||
'-user_agent', userAgent,
|
||||
// Low-latency startup: reduce probe/analyze time for faster first bytes
|
||||
'-probesize', '32768',
|
||||
'-analyzeduration', '500000', // 0.5 seconds - enough to detect audio
|
||||
'-user_agent', userAgent,
|
||||
// Standard probe size to handle complex containers (MKV) correctly
|
||||
'-probesize', '5000000',
|
||||
'-analyzeduration', '5000000',
|
||||
// Error resilience: discard corrupt packets, generate timestamps, ignore DTS, no buffering
|
||||
'-fflags', '+genpts+discardcorrupt+igndts+nobuffer',
|
||||
// Ignore errors in stream and continue
|
||||
@@ -58,6 +59,8 @@ router.get('/', async (req, res) => {
|
||||
'-sn', '-dn',
|
||||
// Copy streams without re-encoding
|
||||
'-c', 'copy',
|
||||
// Ensure extradata is correctly extracted/converted (fixes Annex B -> AVCC issues in Firefox)
|
||||
'-bsf:v', 'dump_extra',
|
||||
// NOTE: We intentionally do NOT use -bsf:a aac_adtstoasc here
|
||||
// That filter only works for AAC audio and breaks AC3/EAC3/MP3.
|
||||
// If AAC audio from MPEG-TS fails in MP4, use /api/transcode instead.
|
||||
|
||||
@@ -29,7 +29,7 @@ transcodeSession.startCleanupInterval();
|
||||
* Body: { url: string, seekOffset?: number }
|
||||
*/
|
||||
router.post('/session', async (req, res) => {
|
||||
const { url, seekOffset } = req.body;
|
||||
const { url, seekOffset, videoMode, videoCodec } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
@@ -46,7 +46,9 @@ router.post('/session', async (req, res) => {
|
||||
seekOffset: seekOffset || 0,
|
||||
hwEncoder: settings.hwEncoder || 'software',
|
||||
maxResolution: settings.maxResolution || '1080p',
|
||||
quality: settings.quality || 'medium'
|
||||
quality: settings.quality || 'medium',
|
||||
videoMode: videoMode, // 'copy' or 'encode'
|
||||
videoCodec: videoCodec // 'h264', 'hevc', etc.
|
||||
});
|
||||
|
||||
await session.start();
|
||||
|
||||
@@ -165,8 +165,9 @@ class TranscodeSession extends EventEmitter {
|
||||
* Build FFmpeg arguments for HLS output with optional GPU encoding
|
||||
*/
|
||||
buildFFmpegArgs() {
|
||||
const segmentPattern = path.join(this.dir, 'seg%04d.ts');
|
||||
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
|
||||
const encoder = this.options.hwEncoder || 'software';
|
||||
const videoMode = this.options.videoMode || 'encode';
|
||||
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
@@ -174,8 +175,10 @@ class TranscodeSession extends EventEmitter {
|
||||
'-user_agent', this.options.userAgent,
|
||||
];
|
||||
|
||||
// Add hardware acceleration input options based on encoder
|
||||
this.addHwAccelInputArgs(args, encoder);
|
||||
// Add hardware acceleration input options based on encoder (only if encoding)
|
||||
if (videoMode === 'encode') {
|
||||
this.addHwAccelInputArgs(args, encoder);
|
||||
}
|
||||
|
||||
// Input options (common)
|
||||
args.push(
|
||||
@@ -186,7 +189,9 @@ class TranscodeSession extends EventEmitter {
|
||||
'-reconnect', '1',
|
||||
'-reconnect_streamed', '1',
|
||||
'-reconnect_delay_max', '3',
|
||||
'-seekable', '0'
|
||||
'-seekable', '0',
|
||||
// Critical for syncing copied video with transcoded audio if source has non-zero start time
|
||||
'-copyts'
|
||||
);
|
||||
|
||||
// Add seek offset if specified
|
||||
@@ -200,14 +205,30 @@ class TranscodeSession extends EventEmitter {
|
||||
args.push('-map', '0:v:0');
|
||||
args.push('-map', '0:a:0?');
|
||||
|
||||
// Add video encoder and filters based on selected encoder
|
||||
this.addVideoEncoderArgs(args, encoder);
|
||||
// Add video encoder and filters based on selected encoder OR copy
|
||||
if (videoMode === 'copy') {
|
||||
args.push('-c:v', 'copy');
|
||||
|
||||
// Critical for MKV/MP4 -> TS copy: Convert bitstream from AVCC/HVCC to Annex B
|
||||
if (this.options.videoCodec === 'hevc' || this.options.videoCodec === 'h265') {
|
||||
args.push('-bsf:v', 'hevc_mp4toannexb');
|
||||
} else if (this.options.videoCodec === 'h264' || this.options.videoCodec === 'avc') {
|
||||
args.push('-bsf:v', 'h264_mp4toannexb');
|
||||
} else {
|
||||
// Fallback (e.g. unknown codec), try strict extraction
|
||||
args.push('-bsf:v', 'dump_extra');
|
||||
}
|
||||
} else {
|
||||
this.addVideoEncoderArgs(args, encoder);
|
||||
}
|
||||
|
||||
// Audio: Transcode to AAC
|
||||
args.push(
|
||||
'-c:a', 'aac',
|
||||
'-ar', '48000',
|
||||
'-b:a', '192k'
|
||||
'-b:a', '192k',
|
||||
// Smart Stereo Downmix (NetV Style): Boosts dialogue (FC) and preserves LFE
|
||||
'-af', 'pan=stereo|FL=FC+0.30*FL+0.30*BL|FR=FC+0.30*FR+0.30*BR,aresample=async=1'
|
||||
);
|
||||
|
||||
// HLS output options
|
||||
@@ -217,7 +238,7 @@ class TranscodeSession extends EventEmitter {
|
||||
'-hls_list_size', '0', // Keep all segments in playlist
|
||||
'-hls_flags', 'independent_segments+append_list',
|
||||
'-hls_segment_type', 'mpegts',
|
||||
'-hls_segment_filename', segmentPattern,
|
||||
'-hls_segment_filename', path.join(this.dir, 'seg%04d.ts'),
|
||||
this.playlistPath
|
||||
);
|
||||
|
||||
@@ -393,7 +414,8 @@ class TranscodeSession extends EventEmitter {
|
||||
'-preset', 'veryfast', // Fast for real-time
|
||||
'-crf', String(crf),
|
||||
'-profile:v', 'high',
|
||||
'-level', '4.1'
|
||||
'-level', '4.1',
|
||||
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility (fixes 10-bit input errors)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user