diff --git a/README.md b/README.md index de1335b..0f13302 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ To properly test and run this project, you will need to prepare your environment - Ensure Node.js (v18+) is installed. - Install **FFmpeg** on your system and make sure it is available in your PATH environment variable. The Node.js library `fluent-ffmpeg` requires it. - If you plan to use Rockchip hardware encoding, make sure your FFmpeg build includes `h264_rkmpp` / `hevc_rkmpp` support and the device has the Rockchip MPP runtime available. - - By default, RKMPP mode uses `/usr/lib/jellyfin-ffmpeg/ffmpeg`. You can override that path with `RKMPP_FFMPEG_PATH`. + - The service always uses Jellyfin FFmpeg by default: `/usr/lib/jellyfin-ffmpeg/ffmpeg` and `/usr/lib/jellyfin-ffmpeg/ffprobe`. You can override them with `JELLYFIN_FFMPEG_PATH` and `JELLYFIN_FFPROBE_PATH`. 2. **AWS S3 / MinIO Configuration**: - Modify the `.env` file (copy from `.env.example`). diff --git a/public/index.html b/public/index.html index 92b9b7e..60ccb72 100644 --- a/public/index.html +++ b/public/index.html @@ -54,22 +54,12 @@
- - + +
- +
diff --git a/public/js/main.js b/public/js/main.js index 8edaaa2..0823d83 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -10,8 +10,10 @@ document.addEventListener('DOMContentLoaded', () => { const loginPasswordInput = document.getElementById('login-password'); const loginBtn = document.getElementById('login-btn'); const loginError = document.getElementById('login-error'); - const codecSelect = document.getElementById('codec-select'); + const decoderSelect = document.getElementById('decoder-select'); const encoderSelect = document.getElementById('encoder-select'); + const decoderLabel = document.querySelector('label[for="decoder-select"]'); + const encoderLabel = document.querySelector('label[for="encoder-select"]'); const playerOverlay = document.getElementById('player-overlay'); const transcodingOverlay = document.getElementById('transcoding-overlay'); const videoPlayer = document.getElementById('video-player'); @@ -89,6 +91,12 @@ document.addEventListener('DOMContentLoaded', () => { if (playbackSpeed) { videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1; } + if (decoderLabel) { + decoderLabel.textContent = '视频解码器:'; + } + if (encoderLabel) { + encoderLabel.textContent = '视频编码器:'; + } const formatBytes = (bytes) => { if (!bytes || bytes === 0) return '0 B'; @@ -114,11 +122,30 @@ document.addEventListener('DOMContentLoaded', () => { const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const populateSelect = (selectEl, options, fallbackValue) => { + if (!selectEl) return; + selectEl.innerHTML = ''; + (options || []).forEach((item) => { + const option = document.createElement('option'); + option.value = item.value; + option.textContent = item.label || item.value; + selectEl.appendChild(option); + }); + + const preferredValue = options?.some((item) => item.value === fallbackValue) + ? fallbackValue + : options?.[0]?.value; + + if (preferredValue) { + selectEl.value = preferredValue; + } + }; + const buildStreamUrl = (targetSeconds = null) => { - const codec = codecSelect?.value || 'h264'; - const encoder = encoderSelect?.value || 'software'; + const decoder = decoderSelect?.value || 'auto'; + const encoder = encoderSelect?.value || 'h264_rkmpp'; const streamSessionId = createStreamSessionId(); - let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&codec=${encodeURIComponent(codec)}&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`; + let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`; if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) { streamUrl += `&ss=${targetSeconds}`; } @@ -584,6 +611,8 @@ document.addEventListener('DOMContentLoaded', () => { topBanner.textContent = title; topBanner.classList.remove('hidden'); document.title = title; + populateSelect(decoderSelect, data.videoDecoders || [], data.defaultVideoDecoder || 'auto'); + populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp'); } catch (err) { console.error('Config load failed:', err); } @@ -908,8 +937,6 @@ document.addEventListener('DOMContentLoaded', () => { transcodingOverlay.classList.remove('hidden'); try { - const codec = codecSelect?.value || 'h264'; - const encoder = encoderSelect?.value || 'software'; if (!selectedBucket) throw new Error('No bucket selected'); const streamUrl = buildStreamUrl(); videoPlayer.src = streamUrl; diff --git a/server.js b/server.js index 3c2848e..0162c67 100644 --- a/server.js +++ b/server.js @@ -15,7 +15,15 @@ const app = express(); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || process.env.LISTEN_ADDRESS || '0.0.0.0'; const server = http.createServer(app); -const RKMPP_FFMPEG_PATH = process.env.RKMPP_FFMPEG_PATH || '/usr/lib/jellyfin-ffmpeg/ffmpeg'; +const JELLYFIN_FFMPEG_PATH = process.env.JELLYFIN_FFMPEG_PATH || '/usr/lib/jellyfin-ffmpeg/ffmpeg'; +const JELLYFIN_FFPROBE_PATH = process.env.JELLYFIN_FFPROBE_PATH || '/usr/lib/jellyfin-ffmpeg/ffprobe'; + +if (typeof ffmpeg.setFfmpegPath === 'function') { + ffmpeg.setFfmpegPath(JELLYFIN_FFMPEG_PATH); +} +if (typeof ffmpeg.setFfprobePath === 'function') { + ffmpeg.setFfprobePath(JELLYFIN_FFPROBE_PATH); +} app.use(cors()); app.use(express.json()); @@ -63,6 +71,34 @@ const progressMap = {}; const transcodeProcesses = new Map(); const wsSubscriptions = new Map(); +const AVAILABLE_VIDEO_ENCODERS = [ + { value: 'libx264', label: 'libx264 (Software H.264)' }, + { value: 'libx265', label: 'libx265 (Software H.265)' }, + { value: 'h264_nvenc', label: 'h264_nvenc (NVIDIA H.264)' }, + { value: 'hevc_nvenc', label: 'hevc_nvenc (NVIDIA HEVC)' }, + { value: 'h264_qsv', label: 'h264_qsv (Intel QSV H.264)' }, + { value: 'hevc_qsv', label: 'hevc_qsv (Intel QSV HEVC)' }, + { value: 'h264_vaapi', label: 'h264_vaapi (VAAPI H.264)' }, + { value: 'hevc_vaapi', label: 'hevc_vaapi (VAAPI HEVC)' }, + { value: 'h264_rkmpp', label: 'h264_rkmpp (RKMPP H.264)' }, + { value: 'hevc_rkmpp', label: 'hevc_rkmpp (RKMPP HEVC)' }, + { value: 'mjpeg_rkmpp', label: 'mjpeg_rkmpp (RKMPP MJPEG)' } +]; + +const AVAILABLE_VIDEO_DECODERS = [ + { value: 'auto', label: 'Auto Select Decoder' }, + { value: 'av1_rkmpp', label: 'av1_rkmpp (RKMPP AV1)' }, + { value: 'h263_rkmpp', label: 'h263_rkmpp (RKMPP H.263)' }, + { value: 'h264_rkmpp', label: 'h264_rkmpp (RKMPP H.264)' }, + { value: 'hevc_rkmpp', label: 'hevc_rkmpp (RKMPP HEVC)' }, + { value: 'mjpeg_rkmpp', label: 'mjpeg_rkmpp (RKMPP MJPEG)' }, + { value: 'mpeg1_rkmpp', label: 'mpeg1_rkmpp (RKMPP MPEG-1)' }, + { value: 'mpeg2_rkmpp', label: 'mpeg2_rkmpp (RKMPP MPEG-2)' }, + { value: 'mpeg4_rkmpp', label: 'mpeg4_rkmpp (RKMPP MPEG-4)' }, + { value: 'vp8_rkmpp', label: 'vp8_rkmpp (RKMPP VP8)' }, + { value: 'vp9_rkmpp', label: 'vp9_rkmpp (RKMPP VP9)' } +]; + const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/'); const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; @@ -112,6 +148,9 @@ const createFfmpegOptions = (encoderName) => { }; const isRkmppCodec = (codecName) => /_rkmpp$/.test(codecName); +const isVaapiCodec = (codecName) => /_vaapi$/.test(codecName); +const availableEncoderValues = new Set(AVAILABLE_VIDEO_ENCODERS.map((item) => item.value)); +const availableDecoderValues = new Set(AVAILABLE_VIDEO_DECODERS.map((item) => item.value)); const getRkmppDecoderName = (metadata) => { const videoStream = (metadata?.streams || []).find((stream) => stream.codec_type === 'video'); @@ -360,7 +399,15 @@ app.get('/api/videos', async (req, res) => { app.get('/api/config', (req, res) => { const title = process.env.APP_TITLE || 'S3 Media Transcoder'; - res.json({ title }); + res.json({ + title, + ffmpegPath: JELLYFIN_FFMPEG_PATH, + ffprobePath: JELLYFIN_FFPROBE_PATH, + defaultVideoEncoder: 'h264_rkmpp', + defaultVideoDecoder: 'auto', + videoEncoders: AVAILABLE_VIDEO_ENCODERS, + videoDecoders: AVAILABLE_VIDEO_DECODERS + }); }); app.post('/api/clear-download-cache', (req, res) => { @@ -402,8 +449,8 @@ app.post('/api/stop-transcode', (req, res) => { app.get('/api/stream', async (req, res) => { const bucket = req.query.bucket; const key = req.query.key; - const codec = req.query.codec; - const encoder = req.query.encoder; + const requestedDecoder = typeof req.query.decoder === 'string' ? req.query.decoder.trim() : 'auto'; + const requestedEncoder = typeof req.query.encoder === 'string' ? req.query.encoder.trim() : 'h264_rkmpp'; const startSeconds = parseFloat(req.query.ss) || 0; const streamSessionId = typeof req.query.streamSessionId === 'string' && req.query.streamSessionId.trim() ? req.query.streamSessionId.trim() @@ -416,17 +463,8 @@ app.get('/api/stream', async (req, res) => { return res.status(400).json({ error: 'Video key is required' }); } - const safeCodec = codec === 'h265' ? 'h265' : 'h264'; - const safeEncoder = ['nvidia', 'intel', 'vaapi', 'rkmpp', 'neon'].includes(encoder) ? encoder : 'software'; - const codecMap = { - software: { h264: 'libx264', h265: 'libx265' }, - neon: { h264: 'libx264', h265: 'libx265' }, - nvidia: { h264: 'h264_nvenc', h265: 'hevc_nvenc' }, - intel: { h264: 'h264_qsv', h265: 'hevc_qsv' }, - vaapi: { h264: 'h264_vaapi', h265: 'hevc_vaapi' }, - rkmpp: { h264: 'h264_rkmpp', h265: 'hevc_rkmpp' } - }; - const videoCodec = codecMap[safeEncoder][safeCodec]; + const videoEncoder = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp'; + const requestedVideoDecoder = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto'; const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); const progressKey = safeKeySegments.join('/'); @@ -543,7 +581,7 @@ app.get('/api/stream', async (req, res) => { let ffmpegCommand = null; - const startStream = (encoderName) => { + const startStream = (encoderName, decoderName) => { const streamingOptions = createFfmpegOptions(encoderName).concat(getSeekFriendlyOutputOptions(encoderName, sourceMetadata)); ffmpegCommand = ffmpeg() .input(tmpInputPath) @@ -552,10 +590,6 @@ app.get('/api/stream', async (req, res) => { .outputOptions(streamingOptions) .format('mp4'); - if (isRkmppCodec(encoderName) && typeof ffmpegCommand.setFfmpegPath === 'function') { - ffmpegCommand.setFfmpegPath(RKMPP_FFMPEG_PATH); - } - if (startSeconds > 0) { if (typeof ffmpegCommand.seekInput === 'function') { ffmpegCommand.seekInput(startSeconds); @@ -564,15 +598,18 @@ app.get('/api/stream', async (req, res) => { } } - if (/_vaapi$/.test(encoderName)) { + if (isVaapiCodec(encoderName)) { ffmpegCommand .inputOptions(['-vaapi_device', '/dev/dri/renderD128']) .videoFilters('format=nv12,hwupload'); - } else if (isRkmppCodec(encoderName)) { - const rkmppDecoder = getRkmppDecoderName(sourceMetadata); - if (rkmppDecoder) { - ffmpegCommand.inputOptions(['-c:v', rkmppDecoder]); - } + } + + const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName) + ? getRkmppDecoderName(sourceMetadata) + : decoderName; + + if (resolvedDecoderName && resolvedDecoderName !== 'auto') { + ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]); } transcodeProcesses.set(progressKey, { command: ffmpegCommand, streamSessionId }); @@ -664,7 +701,7 @@ app.get('/api/stream', async (req, res) => { } }); - startStream(videoCodec); + startStream(videoEncoder, requestedVideoDecoder); } catch (error) { console.error('Error in stream:', error); if (!res.headersSent) {