feat: add Plex-style adaptive transcoding with bitrate limits and hardware fallback
This commit is contained in:
@ -539,7 +539,7 @@
|
|||||||
<h3>Encoder Settings</h3>
|
<h3>Encoder Settings</h3>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info" style="flex: 1;">
|
<div class="setting-info" style="flex: 1;">
|
||||||
<span class="setting-label">Hardware Encoder</span>
|
<span class="setting-label">Transcoding Device</span>
|
||||||
<span class="setting-hint">Select which GPU encoder to use for transcoding. Auto will use the best
|
<span class="setting-hint">Select which GPU encoder to use for transcoding. Auto will use the best
|
||||||
available.</span>
|
available.</span>
|
||||||
</div>
|
</div>
|
||||||
@ -554,7 +554,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info" style="flex: 1;">
|
<div class="setting-info" style="flex: 1;">
|
||||||
<span class="setting-label">Max Resolution</span>
|
<span class="setting-label">Maximum Transcoded Resolution</span>
|
||||||
<span class="setting-hint">Limit transcoded output resolution. Lower = faster, more
|
<span class="setting-hint">Limit transcoded output resolution. Lower = faster, more
|
||||||
compatible.</span>
|
compatible.</span>
|
||||||
</div>
|
</div>
|
||||||
@ -567,7 +567,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info" style="flex: 1;">
|
<div class="setting-info" style="flex: 1;">
|
||||||
<span class="setting-label">Quality Preset</span>
|
<span class="setting-label">Encoding Quality</span>
|
||||||
<span class="setting-hint">Higher quality uses more bandwidth and CPU/GPU.</span>
|
<span class="setting-hint">Higher quality uses more bandwidth and CPU/GPU.</span>
|
||||||
</div>
|
</div>
|
||||||
<select id="setting-quality" class="source-select" style="width: auto; min-width: 200px;">
|
<select id="setting-quality" class="source-select" style="width: auto; min-width: 200px;">
|
||||||
@ -578,7 +578,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info" style="flex: 1;">
|
<div class="setting-info" style="flex: 1;">
|
||||||
<span class="setting-label">Audio Mix Preset</span>
|
<span class="setting-label">Audio Downmix</span>
|
||||||
<span class="setting-hint">How multi-channel audio (5.1/7.1) is mixed down to stereo for browser
|
<span class="setting-hint">How multi-channel audio (5.1/7.1) is mixed down to stereo for browser
|
||||||
playback.</span>
|
playback.</span>
|
||||||
</div>
|
</div>
|
||||||
@ -600,14 +600,14 @@
|
|||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Enable Upscaling</span>
|
<span class="setting-label">Upscale Low-Resolution Video</span>
|
||||||
<span class="setting-hint">Upscale lower-resolution content to a higher resolution during
|
<span class="setting-hint">Upscale lower-resolution content to a higher resolution during
|
||||||
transcoding.</span>
|
transcoding.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item" id="upscale-method-container" style="display: none;">
|
<div class="setting-item" id="upscale-method-container" style="display: none;">
|
||||||
<div class="setting-info" style="flex: 1;">
|
<div class="setting-info" style="flex: 1;">
|
||||||
<span class="setting-label">Upscale Method</span>
|
<span class="setting-label">Upscaling Method</span>
|
||||||
<span class="setting-hint">Hardware is faster; Software (Lanczos) is higher quality.</span>
|
<span class="setting-hint">Hardware is faster; Software (Lanczos) is higher quality.</span>
|
||||||
</div>
|
</div>
|
||||||
<select id="setting-upscale-method" class="source-select" style="width: auto; min-width: 200px;">
|
<select id="setting-upscale-method" class="source-select" style="width: auto; min-width: 200px;">
|
||||||
@ -617,7 +617,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="setting-item" id="upscale-target-container" style="display: none;">
|
<div class="setting-item" id="upscale-target-container" style="display: none;">
|
||||||
<div class="setting-info" style="flex: 1;">
|
<div class="setting-info" style="flex: 1;">
|
||||||
<span class="setting-label">Upscale Target</span>
|
<span class="setting-label">Target Resolution</span>
|
||||||
<span class="setting-hint">Target resolution for upscaled output.</span>
|
<span class="setting-hint">Target resolution for upscaled output.</span>
|
||||||
</div>
|
</div>
|
||||||
<select id="setting-upscale-target" class="source-select" style="width: auto; min-width: 200px;">
|
<select id="setting-upscale-target" class="source-select" style="width: auto; min-width: 200px;">
|
||||||
@ -629,25 +629,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Stream Processing</h3>
|
<h3>Automatic Playback</h3>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<input type="checkbox" id="setting-auto-transcode-tc">
|
<input type="checkbox" id="setting-auto-transcode-tc">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Auto Transcode (Smart)</span>
|
<span class="setting-label">Choose Playback Method Automatically</span>
|
||||||
<span class="setting-hint">Automatically detect stream codecs and only transcode/remux when needed.
|
<span class="setting-hint">Automatically detect stream codecs and only transcode/remux when needed.
|
||||||
Adds ~1-3s probe delay on first play.</span>
|
Adds ~1-3s probe delay on first play.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-info" style="flex: 1;">
|
||||||
|
<span class="setting-label">Maximum Streaming Bandwidth</span>
|
||||||
|
<span class="setting-hint">The bandwidth limit is evaluated when playback starts. Quality does not change automatically during playback.</span>
|
||||||
|
</div>
|
||||||
|
<select id="setting-max-direct-bitrate" class="source-select" style="width: auto; min-width: 200px;">
|
||||||
|
<option value="0">No Limit</option>
|
||||||
|
<option value="20000">20 Mbps</option>
|
||||||
|
<option value="12000">12 Mbps</option>
|
||||||
|
<option value="8000">8 Mbps</option>
|
||||||
|
<option value="4000">4 Mbps</option>
|
||||||
|
<option value="2000">2 Mbps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>Stream Processing</h3>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<label class="setting-toggle">
|
<label class="setting-toggle">
|
||||||
<input type="checkbox" id="setting-force-transcode-tc">
|
<input type="checkbox" id="setting-force-transcode-tc">
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Force Audio Transcode</span>
|
<span class="setting-label">Always Convert Audio</span>
|
||||||
<span class="setting-hint">Transcode audio to AAC for browser compatibility. Enable if you get video
|
<span class="setting-hint">Transcode audio to AAC for browser compatibility. Enable if you get video
|
||||||
but no audio (fixes Dolby/AC3/EAC3).</span>
|
but no audio (fixes Dolby/AC3/EAC3).</span>
|
||||||
</div>
|
</div>
|
||||||
@ -658,7 +676,7 @@
|
|||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Force Video Transcode</span>
|
<span class="setting-label">Always Convert Video</span>
|
||||||
<span class="setting-hint">Force full video transcoding (burn-in subtitles, incompatible video).
|
<span class="setting-hint">Force full video transcoding (burn-in subtitles, incompatible video).
|
||||||
Uses
|
Uses
|
||||||
HW encoder.</span>
|
HW encoder.</span>
|
||||||
@ -670,14 +688,14 @@
|
|||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Force Remux</span>
|
<span class="setting-label">Always Remux Streams</span>
|
||||||
<span class="setting-hint">Remux streams to MP4 container. Enable for raw .ts streams from IPTV
|
<span class="setting-hint">Remux streams to MP4 container. Enable for raw .ts streams from IPTV
|
||||||
middleware like m3u-editor or dispatcharr.</span>
|
middleware like m3u-editor or dispatcharr.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info" style="flex: 1;">
|
<div class="setting-info" style="flex: 1;">
|
||||||
<span class="setting-label">Stream Output Format</span>
|
<span class="setting-label">Stream Container</span>
|
||||||
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
|
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
|
||||||
issues.</span>
|
issues.</span>
|
||||||
</div>
|
</div>
|
||||||
@ -686,6 +704,15 @@
|
|||||||
<option value="ts">MPEG-TS (ts) - Better buffer</option>
|
<option value="ts">MPEG-TS (ts) - Better buffer</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<label class="setting-toggle">
|
||||||
|
<input type="checkbox" id="setting-allow-hardware-fallback">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Use CPU if hardware transcoding fails</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
|
|||||||
@ -53,6 +53,9 @@ class VideoPlayer {
|
|||||||
forceTranscode: false,
|
forceTranscode: false,
|
||||||
forceRemux: false,
|
forceRemux: false,
|
||||||
autoTranscode: true,
|
autoTranscode: true,
|
||||||
|
maxDirectBitrateKbps: 0,
|
||||||
|
targetTranscodeBitrateKbps: 6000,
|
||||||
|
allowHardwareFallback: true,
|
||||||
streamFormat: 'm3u8',
|
streamFormat: 'm3u8',
|
||||||
epgRefreshInterval: '24'
|
epgRefreshInterval: '24'
|
||||||
};
|
};
|
||||||
@ -813,6 +816,7 @@ class VideoPlayer {
|
|||||||
* Start a HLS transcode session
|
* Start a HLS transcode session
|
||||||
*/
|
*/
|
||||||
async startTranscodeSession(url, options = {}) {
|
async startTranscodeSession(url, options = {}) {
|
||||||
|
this.currentTranscodeSession = null;
|
||||||
try {
|
try {
|
||||||
console.log('[Player] Starting HLS transcode session...', options);
|
console.log('[Player] Starting HLS transcode session...', options);
|
||||||
const res = await fetch('/api/transcode/session', {
|
const res = await fetch('/api/transcode/session', {
|
||||||
@ -823,6 +827,7 @@ class VideoPlayer {
|
|||||||
if (!res.ok) throw new Error('Failed to start session');
|
if (!res.ok) throw new Error('Failed to start session');
|
||||||
const session = await res.json();
|
const session = await res.json();
|
||||||
this.currentSessionId = session.sessionId;
|
this.currentSessionId = session.sessionId;
|
||||||
|
this.currentTranscodeSession = session;
|
||||||
return session.playlistUrl;
|
return session.playlistUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Player] Session start failed:', err);
|
console.error('[Player] Session start failed:', err);
|
||||||
@ -871,9 +876,14 @@ class VideoPlayer {
|
|||||||
// Determine if HLS or direct stream
|
// Determine if HLS or direct stream
|
||||||
this.currentUrl = streamUrl;
|
this.currentUrl = streamUrl;
|
||||||
|
|
||||||
// CHECK: Auto Transcode (Smart) - probe first, then decide
|
// Forced playback modes take priority over automatic selection below.
|
||||||
if (this.settings.autoTranscode) {
|
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...');
|
console.log('[Player] Auto Transcode enabled. Probing stream...');
|
||||||
|
this.updateTranscodeStatus('direct', 'Analyzing…');
|
||||||
try {
|
try {
|
||||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
||||||
const info = await probeRes.json();
|
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
|
// 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'})`);
|
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)
|
// Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only)
|
||||||
// BUT: If upscaling is enabled, we MUST encode.
|
// BUT: If upscaling is enabled, we MUST encode.
|
||||||
const videoMode = (info.video && info.video.includes('h264') && !this.settings.upscaleEnabled) ? 'copy' : 'encode';
|
const videoMode = (info.video && info.video.includes('h264') &&
|
||||||
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)');
|
!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';
|
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||||
|
|
||||||
this.updateTranscodeStatus(statusMode, statusText);
|
this.updateTranscodeStatus(statusMode, statusText);
|
||||||
@ -920,8 +939,18 @@ class VideoPlayer {
|
|||||||
videoMode,
|
videoMode,
|
||||||
videoCodec: info.video,
|
videoCodec: info.video,
|
||||||
audioCodec: info.audio,
|
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.currentUrl = playlistUrl; // Update currentUrl for HLS reload
|
||||||
|
|
||||||
this.playHls(playlistUrl);
|
this.playHls(playlistUrl);
|
||||||
@ -934,7 +963,7 @@ class VideoPlayer {
|
|||||||
} else if (info.needsRemux) {
|
} else if (info.needsRemux) {
|
||||||
// Raw .ts container - use remux
|
// Raw .ts container - use remux
|
||||||
console.log('[Player] Auto: Using remux (.ts container)');
|
console.log('[Player] Auto: Using remux (.ts container)');
|
||||||
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
this.updateTranscodeStatus('remuxing', 'Remux');
|
||||||
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
||||||
this.currentUrl = remuxUrl;
|
this.currentUrl = remuxUrl;
|
||||||
this.video.src = remuxUrl;
|
this.video.src = remuxUrl;
|
||||||
@ -949,6 +978,7 @@ class VideoPlayer {
|
|||||||
}
|
}
|
||||||
// Compatible - fall through to normal HLS.js path
|
// Compatible - fall through to normal HLS.js path
|
||||||
console.log('[Player] Auto: Using HLS.js (compatible)');
|
console.log('[Player] Auto: Using HLS.js (compatible)');
|
||||||
|
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[Player] Probe failed, using normal playback:', err.message);
|
console.warn('[Player] Probe failed, using normal playback:', err.message);
|
||||||
// Continue with normal playback on probe failure
|
// Continue with normal playback on probe failure
|
||||||
@ -957,11 +987,19 @@ class VideoPlayer {
|
|||||||
|
|
||||||
// CHECK: Force Video Transcode (Full) or Upscaling
|
// CHECK: Force Video Transcode (Full) or Upscaling
|
||||||
if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) {
|
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';
|
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||||
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
|
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
|
||||||
this.updateTranscodeStatus(statusMode, statusText);
|
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;
|
this.currentUrl = playlistUrl;
|
||||||
|
|
||||||
// Load HLS
|
// Load HLS
|
||||||
@ -1002,7 +1040,7 @@ class VideoPlayer {
|
|||||||
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
|
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
|
||||||
if (this.settings.forceTranscode) {
|
if (this.settings.forceTranscode) {
|
||||||
console.log('[Player] Force Audio Transcode enabled. Starting session (copy)...');
|
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
|
// Probe to get video codec for HEVC tag handling
|
||||||
let videoCodec = 'unknown';
|
let videoCodec = 'unknown';
|
||||||
@ -1012,7 +1050,11 @@ class VideoPlayer {
|
|||||||
videoCodec = info.video;
|
videoCodec = info.video;
|
||||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
} 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;
|
this.currentUrl = playlistUrl;
|
||||||
|
|
||||||
console.log('[Player] Playing transcoded HLS stream:', playlistUrl);
|
console.log('[Player] Playing transcoded HLS stream:', playlistUrl);
|
||||||
@ -1053,7 +1095,7 @@ class VideoPlayer {
|
|||||||
if (this.settings.forceRemux && (isRawTs || isExtensionless)) {
|
if (this.settings.forceRemux && (isRawTs || isExtensionless)) {
|
||||||
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
|
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
|
||||||
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)');
|
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);
|
const remuxUrl = this.getRemuxUrl(streamUrl);
|
||||||
this.video.src = remuxUrl;
|
this.video.src = remuxUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
@ -1082,7 +1124,7 @@ class VideoPlayer {
|
|||||||
|
|
||||||
// Priority 1: Use HLS.js for HLS streams on browsers that support it
|
// Priority 1: Use HLS.js for HLS streams on browsers that support it
|
||||||
if (looksLikeHls && Hls.isSupported()) {
|
if (looksLikeHls && Hls.isSupported()) {
|
||||||
this.updateTranscodeStatus('direct', 'Direct HLS');
|
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||||
|
|
||||||
// Use playHls helper logic here (or extract it)
|
// Use playHls helper logic here (or extract it)
|
||||||
// For now, let's just use existing logic but wrapped/modularized if possible?
|
// 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' ||
|
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
|
||||||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
|
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
|
||||||
// Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work)
|
// 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.src = finalUrl;
|
||||||
this.video.play().catch(e => {
|
this.video.play().catch(e => {
|
||||||
if (e.name === 'AbortError') return; // Ignore interruption by new load
|
if (e.name === 'AbortError') return; // Ignore interruption by new load
|
||||||
@ -1247,11 +1289,15 @@ class VideoPlayer {
|
|||||||
/**
|
/**
|
||||||
* Update quality badge display
|
* Update quality badge display
|
||||||
*/
|
*/
|
||||||
updateQualityBadge() {
|
updateQualityBadge(targetBitrateKbps = null) {
|
||||||
const badge = document.getElementById('player-quality-badge');
|
const badge = document.getElementById('player-quality-badge');
|
||||||
if (!badge) return;
|
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.textContent = this.getQualityLabel(this.currentStreamInfo.height);
|
||||||
badge.classList.remove('hidden');
|
badge.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -107,6 +107,8 @@ class SettingsPage {
|
|||||||
const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc');
|
const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc');
|
||||||
const forceRemuxToggle = document.getElementById('setting-force-remux-tc');
|
const forceRemuxToggle = document.getElementById('setting-force-remux-tc');
|
||||||
const streamFormatSelect = document.getElementById('setting-stream-format-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)
|
// User-Agent (Transcoding tab versions)
|
||||||
const userAgentSelect = document.getElementById('setting-user-agent-tc');
|
const userAgentSelect = document.getElementById('setting-user-agent-tc');
|
||||||
@ -131,6 +133,8 @@ class SettingsPage {
|
|||||||
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
|
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
|
||||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
|
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 (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
|
||||||
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
|
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
|
||||||
if (customUaContainer) {
|
if (customUaContainer) {
|
||||||
@ -232,6 +236,16 @@ class SettingsPage {
|
|||||||
this.app.player.saveSettings();
|
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
|
// User-Agent handlers
|
||||||
const toggleCustomInput = () => {
|
const toggleCustomInput = () => {
|
||||||
if (customUaContainer) {
|
if (customUaContainer) {
|
||||||
|
|||||||
@ -76,6 +76,9 @@ function getDefaultSettings() {
|
|||||||
hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software
|
hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software
|
||||||
maxResolution: '1080p', // 4k | 1080p | 720p | 480p
|
maxResolution: '1080p', // 4k | 1080p | 720p | 480p
|
||||||
quality: 'medium', // high | medium | low
|
quality: 'medium', // high | medium | low
|
||||||
|
maxDirectBitrateKbps: 0, // 0 = unlimited
|
||||||
|
targetTranscodeBitrateKbps: 6000,
|
||||||
|
allowHardwareFallback: true,
|
||||||
audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough
|
audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough
|
||||||
// Probe cache settings
|
// Probe cache settings
|
||||||
probeCacheTTL: 300, // 5 minutes for URL probe cache
|
probeCacheTTL: 300, // 5 minutes for URL probe cache
|
||||||
|
|||||||
@ -87,6 +87,22 @@ function analyzeProbeResult(probeResult, url) {
|
|||||||
const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown';
|
const videoCodec = videoStream?.codec_name?.toLowerCase() || 'unknown';
|
||||||
const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown';
|
const audioCodec = audioStream?.codec_name?.toLowerCase() || 'unknown';
|
||||||
const container = format.format_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
|
// Check codec compatibility
|
||||||
const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c));
|
const videoOk = BROWSER_VIDEO_CODECS.some(c => videoCodec.includes(c));
|
||||||
@ -131,6 +147,11 @@ function analyzeProbeResult(probeResult, url) {
|
|||||||
width: videoStream?.width || 0,
|
width: videoStream?.width || 0,
|
||||||
height: videoStream?.height || 0,
|
height: videoStream?.height || 0,
|
||||||
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
|
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
|
||||||
|
durationSeconds,
|
||||||
|
videoBitrateKbps,
|
||||||
|
audioBitrateKbps,
|
||||||
|
overallBitrateKbps,
|
||||||
|
probeConfidence,
|
||||||
container: container,
|
container: container,
|
||||||
compatible: compatible,
|
compatible: compatible,
|
||||||
needsRemux: needsRemux,
|
needsRemux: needsRemux,
|
||||||
@ -157,7 +178,12 @@ router.get('/', async (req, res) => {
|
|||||||
container: 'unknown',
|
container: 'unknown',
|
||||||
compatible: false,
|
compatible: false,
|
||||||
needsRemux: 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,
|
compatible: false,
|
||||||
needsRemux: false,
|
needsRemux: false,
|
||||||
needsTranscode: true,
|
needsTranscode: true,
|
||||||
|
durationSeconds: null,
|
||||||
|
videoBitrateKbps: null,
|
||||||
|
audioBitrateKbps: null,
|
||||||
|
overallBitrateKbps: null,
|
||||||
|
probeConfidence: 'partial',
|
||||||
error: err.message
|
error: err.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,10 @@ transcodeSession.startCleanupInterval();
|
|||||||
* Body: { url: string, seekOffset?: number }
|
* Body: { url: string, seekOffset?: number }
|
||||||
*/
|
*/
|
||||||
router.post('/session', async (req, res) => {
|
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) {
|
if (!url) {
|
||||||
return res.status(400).json({ error: 'URL is required' });
|
return res.status(400).json({ error: 'URL is required' });
|
||||||
@ -47,6 +50,9 @@ router.post('/session', async (req, res) => {
|
|||||||
hwEncoder: settings.hwEncoder || 'software',
|
hwEncoder: settings.hwEncoder || 'software',
|
||||||
maxResolution: settings.maxResolution || '1080p',
|
maxResolution: settings.maxResolution || '1080p',
|
||||||
quality: settings.quality || 'medium',
|
quality: settings.quality || 'medium',
|
||||||
|
targetVideoBitrateKbps: targetVideoBitrateKbps ?? settings.targetTranscodeBitrateKbps ?? 6000,
|
||||||
|
targetAudioBitrateKbps: targetAudioBitrateKbps ?? 192,
|
||||||
|
allowHardwareFallback: allowHardwareFallback ?? settings.allowHardwareFallback ?? true,
|
||||||
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
|
audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset
|
||||||
// Upscaling options
|
// Upscaling options
|
||||||
upscaleEnabled: settings.upscaleEnabled || false,
|
upscaleEnabled: settings.upscaleEnabled || false,
|
||||||
@ -71,7 +77,10 @@ router.post('/session', async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
|
playlistUrl: `/api/transcode/${session.id}/stream.m3u8`,
|
||||||
status: session.status
|
status: session.status,
|
||||||
|
requestedEncoder: session.requestedEncoder,
|
||||||
|
effectiveEncoder: session.effectiveEncoder,
|
||||||
|
hardwareFallbackOccurred: session.hardwareFallbackOccurred
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -270,4 +279,3 @@ router.get('/', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@ -63,20 +63,28 @@ class TranscodeSession extends EventEmitter {
|
|||||||
this.segments = new Map(); // segment index -> { ready: boolean, path: string }
|
this.segments = new Map(); // segment index -> { ready: boolean, path: string }
|
||||||
this.status = 'pending'; // pending | starting | running | stopped | error
|
this.status = 'pending'; // pending | starting | running | stopped | error
|
||||||
this.error = null;
|
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.startTime = Date.now();
|
||||||
this.lastAccess = Date.now();
|
this.lastAccess = Date.now();
|
||||||
this.options = {
|
this.options = {
|
||||||
|
...options,
|
||||||
ffmpegPath: options.ffmpegPath || 'ffmpeg',
|
ffmpegPath: options.ffmpegPath || 'ffmpeg',
|
||||||
userAgent: options.userAgent || 'Mozilla/5.0',
|
userAgent: options.userAgent || 'Mozilla/5.0',
|
||||||
seekOffset: options.seekOffset || 0,
|
seekOffset: options.seekOffset || 0,
|
||||||
hwEncoder: options.hwEncoder || 'software',
|
hwEncoder: options.hwEncoder || 'software',
|
||||||
maxResolution: options.maxResolution || '1080p',
|
maxResolution: options.maxResolution || '1080p',
|
||||||
quality: options.quality || 'medium',
|
quality: options.quality || 'medium',
|
||||||
|
targetVideoBitrateKbps: this.normalizeBitrate(options.targetVideoBitrateKbps, 6000),
|
||||||
|
targetAudioBitrateKbps: this.normalizeBitrate(options.targetAudioBitrateKbps, 192),
|
||||||
|
allowHardwareFallback: options.allowHardwareFallback !== false,
|
||||||
// Upscaling options
|
// Upscaling options
|
||||||
upscaleEnabled: options.upscaleEnabled || false,
|
upscaleEnabled: options.upscaleEnabled || false,
|
||||||
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
|
upscaleMethod: options.upscaleMethod || 'hardware', // 'hardware' or 'software'
|
||||||
upscaleTarget: options.upscaleTarget || '1080p',
|
upscaleTarget: options.upscaleTarget || '1080p'
|
||||||
...options
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,27 +108,62 @@ class TranscodeSession extends EventEmitter {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build FFmpeg arguments for HLS output
|
try {
|
||||||
const args = this.buildFFmpegArgs();
|
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(' ')}`);
|
console.log(`[TranscodeSession ${this.id}] Command: ${this.options.ffmpegPath} ${args.join(' ')}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.process = spawn(this.options.ffmpegPath, args, {
|
const process = spawn(this.options.ffmpegPath, args, {
|
||||||
cwd: this.dir,
|
cwd: this.dir,
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
});
|
});
|
||||||
|
this.process = process;
|
||||||
|
|
||||||
this.status = 'running';
|
this.status = 'running';
|
||||||
|
|
||||||
// Handle stdout (should be empty for file output)
|
// 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}`);
|
console.log(`[TranscodeSession ${this.id}] stdout: ${data}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle stderr (FFmpeg progress/errors)
|
// Handle stderr (FFmpeg progress/errors)
|
||||||
let stderrBuffer = '';
|
let stderrBuffer = '';
|
||||||
this.process.stderr.on('data', (data) => {
|
process.stderr.on('data', (data) => {
|
||||||
stderrBuffer += data.toString();
|
stderrBuffer += data.toString();
|
||||||
// Log periodically to avoid spam
|
// Log periodically to avoid spam
|
||||||
const lines = stderrBuffer.split('\n');
|
const lines = stderrBuffer.split('\n');
|
||||||
@ -135,51 +178,62 @@ class TranscodeSession extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle process exit
|
// Handle process exit
|
||||||
this.process.on('exit', (code) => {
|
process.on('exit', (code) => {
|
||||||
|
if (generation !== this.processGeneration) return;
|
||||||
if (code === 0 || code === null) {
|
if (code === 0 || code === null) {
|
||||||
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
|
console.log(`[TranscodeSession ${this.id}] FFmpeg completed successfully`);
|
||||||
this.status = 'stopped';
|
this.status = 'stopped';
|
||||||
} else if (code !== 255) { // 255 is often from SIGKILL
|
} 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.status = 'error';
|
||||||
this.error = `FFmpeg exited with code ${code}`;
|
this.error = error;
|
||||||
}
|
}
|
||||||
this.process = null;
|
this.process = null;
|
||||||
this.emit('exit', code);
|
this.emit('exit', code);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle spawn errors
|
// 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);
|
console.error(`[TranscodeSession ${this.id}] FFmpeg error:`, err);
|
||||||
this.status = 'error';
|
this.status = 'error';
|
||||||
this.error = err.message;
|
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) {
|
} catch (err) {
|
||||||
this.status = 'error';
|
if (this.tryHardwareFallback(err.message, encoder)) return;
|
||||||
this.error = err.message;
|
|
||||||
throw err;
|
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
|
* Build FFmpeg arguments for HLS output with optional GPU encoding
|
||||||
*/
|
*/
|
||||||
buildFFmpegArgs() {
|
buildFFmpegArgs(resolvedEncoder = null) {
|
||||||
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
|
const segmentPattern = path.join(this.dir, 'seg%04d.m4s');
|
||||||
const videoMode = this.options.videoMode || 'encode';
|
const videoMode = this.options.videoMode || 'encode';
|
||||||
|
|
||||||
// Resolve 'auto' encoder to detected hardware, fallback to software
|
// Resolve 'auto' encoder to detected hardware, fallback to software
|
||||||
let encoder = this.options.hwEncoder || 'software';
|
const encoder = resolvedEncoder || this.resolveEncoder();
|
||||||
if (encoder === 'auto') {
|
|
||||||
const hwCaps = hwDetect.getCapabilities();
|
|
||||||
encoder = hwCaps?.recommended || 'software';
|
|
||||||
console.log(`[TranscodeSession ${this.id}] Auto encoder resolved to: ${encoder}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
@ -264,7 +318,7 @@ class TranscodeSession extends EventEmitter {
|
|||||||
args.push(
|
args.push(
|
||||||
'-c:a', 'aac',
|
'-c:a', 'aac',
|
||||||
'-ar', '48000',
|
'-ar', '48000',
|
||||||
'-b:a', '192k',
|
'-b:a', `${this.options.targetAudioBitrateKbps}k`,
|
||||||
'-af', `${panFilter},aresample=async=1`
|
'-af', `${panFilter},aresample=async=1`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -336,6 +390,7 @@ class TranscodeSession extends EventEmitter {
|
|||||||
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
|
'low': { nvenc: 30, vaapi: 30, qsv: 30, amf: 30, software: 28 }
|
||||||
};
|
};
|
||||||
const qp = qualityPresets[quality] || qualityPresets.medium;
|
const qp = qualityPresets[quality] || qualityPresets.medium;
|
||||||
|
const bitrate = this.options.targetVideoBitrateKbps;
|
||||||
|
|
||||||
switch (encoder) {
|
switch (encoder) {
|
||||||
case 'nvenc':
|
case 'nvenc':
|
||||||
@ -356,6 +411,7 @@ class TranscodeSession extends EventEmitter {
|
|||||||
this.addSoftwareEncoderArgs(args, resolution, qp.software);
|
this.addSoftwareEncoderArgs(args, resolution, qp.software);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
args.push('-b:v', `${bitrate}k`, '-maxrate', `${bitrate}k`, '-bufsize', `${bitrate * 2}k`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -432,8 +488,8 @@ class TranscodeSession extends EventEmitter {
|
|||||||
args.push(
|
args.push(
|
||||||
'-c:v', 'h264_nvenc',
|
'-c:v', 'h264_nvenc',
|
||||||
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
|
'-preset', 'p4', // Balanced preset (p1=fastest, p7=best)
|
||||||
'-rc', 'constqp', // Constant QP mode
|
'-rc', 'vbr', // Bitrate-constrained variable bitrate
|
||||||
'-qp', String(qp),
|
'-cq', String(qp), // Quality target within bitrate constraint
|
||||||
'-bf', '3' // B-frames for better compression
|
'-bf', '3' // B-frames for better compression
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -448,10 +504,7 @@ class TranscodeSession extends EventEmitter {
|
|||||||
args.push(
|
args.push(
|
||||||
'-c:v', 'h264_amf',
|
'-c:v', 'h264_amf',
|
||||||
'-quality', 'quality', // Quality preset
|
'-quality', 'quality', // Quality preset
|
||||||
'-rc', 'cqp', // Constant QP
|
'-rc', 'vbr_peak', // Bitrate-constrained variable bitrate
|
||||||
'-qp_i', String(qp),
|
|
||||||
'-qp_p', String(qp + 2),
|
|
||||||
'-qp_b', String(qp + 4),
|
|
||||||
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
'-pix_fmt', 'yuv420p' // Force 8-bit output for compatibility
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -556,6 +609,7 @@ class TranscodeSession extends EventEmitter {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
while (Date.now() - startTime < timeoutMs) {
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
if (await this.isPlaylistReady()) {
|
if (await this.isPlaylistReady()) {
|
||||||
|
this.playlistReady = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, 200));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
@ -600,6 +654,9 @@ class TranscodeSession extends EventEmitter {
|
|||||||
startTime: this.startTime,
|
startTime: this.startTime,
|
||||||
lastAccess: this.lastAccess,
|
lastAccess: this.lastAccess,
|
||||||
options: this.options,
|
options: this.options,
|
||||||
|
requestedEncoder: this.requestedEncoder,
|
||||||
|
effectiveEncoder: this.effectiveEncoder,
|
||||||
|
hardwareFallbackOccurred: this.hardwareFallbackOccurred,
|
||||||
seekOffset: this.options.seekOffset
|
seekOffset: this.options.seekOffset
|
||||||
};
|
};
|
||||||
const metaPath = path.join(this.dir, 'session.json');
|
const metaPath = path.join(this.dir, 'session.json');
|
||||||
@ -621,6 +678,9 @@ class TranscodeSession extends EventEmitter {
|
|||||||
session.startTime = metadata.startTime;
|
session.startTime = metadata.startTime;
|
||||||
session.lastAccess = metadata.lastAccess;
|
session.lastAccess = metadata.lastAccess;
|
||||||
session.status = 'stopped'; // Not running after restart
|
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;
|
return session;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to restore session from ${sessionDir}:`, err.message);
|
console.error(`Failed to restore session from ${sessionDir}:`, err.message);
|
||||||
@ -753,7 +813,10 @@ function getAllSessions() {
|
|||||||
status: s.status,
|
status: s.status,
|
||||||
startTime: s.startTime,
|
startTime: s.startTime,
|
||||||
lastAccess: s.lastAccess,
|
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