Merge pull request #16 from technomancer702/feature/ffmpeg-transcoding

Feature/ffmpeg transcoding
This commit is contained in:
Technomancer702
2025-12-29 17:50:18 -08:00
committed by GitHub
7 changed files with 441 additions and 3 deletions
+225
View File
@@ -14,8 +14,34 @@
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"ffmpeg-static": "^5.2.0"
}
},
"node_modules/@derhuerst/http-basic": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
"license": "MIT",
"optional": true,
"dependencies": {
"caseless": "^0.12.0",
"concat-stream": "^2.0.0",
"http-response-object": "^3.0.1",
"parse-cache-control": "^1.0.1"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@types/node": {
"version": "10.17.60",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
"license": "MIT",
"optional": true
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -29,6 +55,44 @@
"node": ">= 0.6"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/agent-base/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"optional": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/agent-base/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT",
"optional": true
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -59,6 +123,13 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT",
"optional": true
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -97,6 +168,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0",
"optional": true
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"optional": true,
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -190,6 +284,16 @@
"node": ">= 0.8"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -281,6 +385,23 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/ffmpeg-static": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"optional": true,
"dependencies": {
"@derhuerst/http-basic": "^8.2.0",
"env-paths": "^2.2.0",
"https-proxy-agent": "^5.0.0",
"progress": "^2.0.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -419,6 +540,55 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/http-response-object": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/node": "^10.0.3"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"optional": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/https-proxy-agent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"optional": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/https-proxy-agent/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT",
"optional": true
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -554,6 +724,12 @@
"node": ">= 0.8"
}
},
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==",
"optional": true
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -569,6 +745,16 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -621,6 +807,21 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -785,6 +986,16 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -807,6 +1018,13 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT",
"optional": true
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -816,6 +1034,13 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"optional": true
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+3
View File
@@ -12,6 +12,9 @@
"express": "^4.18.2",
"xml2js": "^0.6.2"
},
"optionalDependencies": {
"ffmpeg-static": "^5.2.0"
},
"engines": {
"node": ">=18.0.0"
}
+11
View File
@@ -343,6 +343,17 @@
directly.</span>
</div>
</div>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-force-transcode">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Force Audio Transcode</span>
<span class="setting-hint">Transcode audio to AAC for browser compatibility. Enable if you get video
but no audio (fixes Dolby/AC3/EAC3). Requires ffmpeg-static.</span>
</div>
</div>
<div class="setting-item">
<div class="setting-info" style="flex: 1;">
<span class="setting-label">Stream Output Format</span>
+82 -3
View File
@@ -33,7 +33,8 @@ class VideoPlayer {
rememberVolume: true, // Persist volume between sessions
lastVolume: 80, // Last used volume (if rememberVolume is true)
autoPlayNextEpisode: false, // Auto-play next episode in series
forceProxy: false // Force all streams through backend proxy
forceProxy: false, // Force all streams through backend proxy
forceTranscode: false // Force audio transcoding (fixes Dolby/AC3 audio)
};
try {
const saved = localStorage.getItem('nodecast_tv_player_settings');
@@ -67,12 +68,16 @@ class VideoPlayer {
maxBufferLength: 30, // Buffer up to 30 seconds of content
maxMaxBufferLength: 60, // Absolute max buffer 60 seconds
maxBufferSize: 60 * 1000 * 1000, // 60MB max buffer size
maxBufferHole: 0.5, // Allow 0.5s holes in buffer (helps with discontinuities)
// Live stream settings - stay further from live edge for stability
liveSyncDurationCount: 3, // Stay 3 segments behind live
liveMaxLatencyDurationCount: 10, // Allow up to 10 segments behind before catching up
// Handle audio discontinuities (fixes garbled audio during ad transitions)
liveBackBufferLength: 30, // Keep 30s of back buffer for seeking
// Audio discontinuity handling (fixes garbled audio during ad transitions)
stretchShortVideoTrack: true, // Stretch short segments to avoid gaps
forceKeyFrameOnDiscontinuity: true, // Force keyframe sync on discontinuity
// More aggressive discontinuity recovery
maxAudioFramesDrift: 1, // Allow minimal audio drift before correction
// Faster recovery from errors
levelLoadingMaxRetry: 4,
manifestLoadingMaxRetry: 4,
@@ -100,9 +105,10 @@ class VideoPlayer {
// Initialize HLS.js if supported
if (Hls.isSupported()) {
this.hls = new Hls(this.getHlsConfig());
this.lastDiscontinuity = -1; // Track discontinuity changes
this.hls.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS error:', data);
console.error('HLS error:', data.type, data.details);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
@@ -130,6 +136,10 @@ class VideoPlayer {
// Non-fatal media error - try to recover (handles audio codec issues)
console.log('Non-fatal media error, attempting recovery...');
this.hls.recoverMediaError();
} else if (data.details === 'bufferAppendError' || data.details === 'fragParsingError') {
// Buffer/parsing errors during ad transitions - try recovery
console.log('Buffer/parsing error during segment, recovering...');
this.hls.recoverMediaError();
}
});
@@ -144,6 +154,27 @@ class VideoPlayer {
this.hls.recoverMediaError();
});
// Detect discontinuity changes (ad transitions) and help decoder reset
this.hls.on(Hls.Events.FRAG_CHANGED, (event, data) => {
const frag = data.frag;
// Debug: log every fragment change
console.log(`[HLS] FRAG_CHANGED: sn=${frag?.sn}, cc=${frag?.cc}, level=${frag?.level}`);
if (frag && frag.sn !== 'initSegment') {
// Check if we crossed a discontinuity boundary using CC (Continuity Counter)
if (frag.cc !== undefined && frag.cc !== this.lastDiscontinuity) {
console.log(`[HLS] Discontinuity detected: CC ${this.lastDiscontinuity} -> ${frag.cc}`);
this.lastDiscontinuity = frag.cc;
// Small nudge to help decoder sync (only if playing)
if (!this.video.paused && this.video.currentTime > 0) {
const nudgeAmount = 0.01;
this.video.currentTime += nudgeAmount;
}
}
}
});
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => console.log('Autoplay prevented:', e));
});
@@ -202,6 +233,25 @@ class VideoPlayer {
// Determine if HLS or direct stream
this.currentUrl = streamUrl;
// CHECK: Force Transcode Priority - transcoded streams bypass HLS.js
if (this.settings.forceTranscode) {
console.log('[Player] Force Transcode enabled. Routing through ffmpeg...');
const transcodeUrl = this.getTranscodeUrl(streamUrl);
this.currentUrl = transcodeUrl;
// Transcoded streams are fragmented MP4 - play directly with <video> element
console.log('[Player] Playing transcoded stream directly:', transcodeUrl);
this.video.src = transcodeUrl;
this.video.play().catch(e => console.log('[Player] Autoplay prevented:', e));
// Update UI and dispatch events
this.updateNowPlaying(channel);
this.showNowPlayingOverlay();
this.fetchEpgData(channel);
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
return; // Exit early - don't use HLS.js path
}
// Proactively use proxy for:
// 1. User enabled "Force Proxy" in settings
// 2. Known CORS-restricted domains (like Pluto TV)
@@ -245,6 +295,28 @@ class VideoPlayer {
} else {
console.error('Fatal HLS error:', data);
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
// Non-fatal media error - just log it (auto-recovery can cause black screens)
console.log('Non-fatal media error:', data.details);
}
});
// Detect discontinuity changes (ad transitions) and help decoder reset
this.lastDiscontinuity = -1;
this.hls.on(Hls.Events.FRAG_CHANGED, (event, data) => {
const frag = data.frag;
if (frag && frag.sn !== 'initSegment') {
// Check if we crossed a discontinuity boundary using CC (Continuity Counter)
if (frag.cc !== undefined && frag.cc !== this.lastDiscontinuity) {
console.log(`[HLS] Discontinuity detected: CC ${this.lastDiscontinuity} -> ${frag.cc}`);
this.lastDiscontinuity = frag.cc;
// Small nudge to help decoder sync (only if playing)
if (!this.video.paused && this.video.currentTime > 0) {
const nudgeAmount = 0.01;
this.video.currentTime += nudgeAmount;
}
}
}
});
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
@@ -377,6 +449,13 @@ class VideoPlayer {
return `/api/proxy/stream?url=${encodeURIComponent(url)}`;
}
/**
* Get transcoded URL for a stream (audio transcoding for browser compatibility)
*/
getTranscodeUrl(url) {
return `/api/transcode?url=${encodeURIComponent(url)}`;
}
/**
* Decode base64 EPG data
*/
+10
View File
@@ -29,6 +29,7 @@ class SettingsPage {
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
const forceProxyToggle = document.getElementById('setting-force-proxy');
const forceTranscodeToggle = document.getElementById('setting-force-transcode');
// Load current settings
if (this.app.player?.settings) {
@@ -41,6 +42,9 @@ class SettingsPage {
if (forceProxyToggle) {
forceProxyToggle.checked = this.app.player.settings.forceProxy || false;
}
if (forceTranscodeToggle) {
forceTranscodeToggle.checked = this.app.player.settings.forceTranscode || false;
}
}
// Arrow keys toggle
@@ -83,6 +87,12 @@ class SettingsPage {
this.app.player.saveSettings();
});
// Force transcode toggle
forceTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceTranscode = forceTranscodeToggle.checked;
this.app.player.saveSettings();
});
// EPG refresh interval
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
if (epgRefreshSelect) {
+17
View File
@@ -11,11 +11,28 @@ const PORT = process.env.PORT || 3000;
app.use(express.json({ limit: '50mb' }));
app.use(express.static(path.join(__dirname, '..', 'public')));
// FFMPEG Configuration (optional - for transcoding support)
try {
let ffmpegPath = require('ffmpeg-static');
// In packaged Electron apps, ffmpeg-static returns path inside .asar archive
// but the binary is actually unpacked to app.asar.unpacked
if (ffmpegPath && ffmpegPath.includes('app.asar')) {
ffmpegPath = ffmpegPath.replace('app.asar', 'app.asar.unpacked');
}
app.locals.ffmpegPath = ffmpegPath;
console.log('FFmpeg binary configured at:', ffmpegPath);
} catch (err) {
console.warn('FFmpeg not available - transcoding will be disabled. Install ffmpeg-static for transcoding support.');
}
// API Routes
app.use('/api/sources', require('./routes/sources'));
app.use('/api/proxy', require('./routes/proxy'));
app.use('/api/channels', require('./routes/channels'));
app.use('/api/favorites', require('./routes/favorites'));
app.use('/api/transcode', require('./routes/transcode'));
// SPA fallback - serve index.html for all non-API routes
app.get('*', (req, res) => {
+93
View File
@@ -0,0 +1,93 @@
const express = require('express');
const router = express.Router();
const { spawn } = require('child_process');
/**
* Transcode stream
* GET /api/transcode?url=...
*
* Transcodes audio to AAC for browser compatibility while passing video through.
* This fixes playback issues with Dolby/AC3/EAC3 audio that browsers can't decode.
*/
router.get('/', (req, res) => {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'URL parameter is required' });
}
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
console.log(`[Transcode] Starting transcoding for: ${url}`);
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
// FFmpeg arguments for transcoding
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
// NOT recommended for live streams - use HLS.js directly for those
const args = [
'-hide_banner',
'-loglevel', 'warning',
'-fflags', '+genpts',
'-i', url,
// Video: passthrough (no re-encoding = fast!)
'-c:v', 'copy',
// Audio: Transcode to browser-compatible AAC with consistent parameters
'-c:a', 'aac',
'-ac', '2',
'-ar', '48000',
'-b:a', '128k',
'-af', 'aresample=48000:async=1',
// Fragmented MP4 for streaming (browser-compatible)
'-f', 'mp4',
'-movflags', 'frag_keyframe+empty_moov+default_base_moof',
'-' // Output to stdout
];
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
let ffmpeg;
try {
ffmpeg = spawn(ffmpegPath, args);
} catch (spawnErr) {
console.error('[Transcode] Failed to spawn FFmpeg:', spawnErr);
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
}
// Collect stderr for error reporting
let stderrBuffer = '';
// Set headers for fragmented MP4
res.setHeader('Content-Type', 'video/mp4');
res.setHeader('Access-Control-Allow-Origin', '*');
// Pipe stdout to response
ffmpeg.stdout.pipe(res);
// Log stderr (useful for debugging transcoding failures)
ffmpeg.stderr.on('data', (data) => {
const msg = data.toString();
stderrBuffer += msg;
console.log(`[FFmpeg] ${msg}`);
});
// Cleanup on client disconnect
req.on('close', () => {
console.log('[Transcode] Client disconnected, killing FFmpeg process');
ffmpeg.kill('SIGKILL');
});
// Handle process exit
ffmpeg.on('exit', (code) => {
if (code !== null && code !== 0 && code !== 255) { // 255 is often returned on kill
console.error(`[Transcode] FFmpeg exited with code ${code}`);
}
});
// Handle spawn errors
ffmpeg.on('error', (err) => {
console.error('[Transcode] Failed to spawn FFmpeg:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Transcoding failed to start' });
}
});
});
module.exports = router;