添加RKMPP

This commit is contained in:
CN-JS-HuiBai
2026-04-03 21:31:38 +08:00
parent 2288370976
commit d5b70833e6
4 changed files with 101 additions and 47 deletions

View File

@@ -6,7 +6,7 @@ To properly test and run this project, you will need to prepare your environment
- Ensure Node.js (v18+) is installed. - 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. - 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. - 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**: 2. **AWS S3 / MinIO Configuration**:
- Modify the `.env` file (copy from `.env.example`). - Modify the `.env` file (copy from `.env.example`).

View File

@@ -54,22 +54,12 @@
</select> </select>
</div> </div>
<div class="codec-panel"> <div class="codec-panel">
<label for="codec-select">编码方式</label> <label for="decoder-select">视频解码器</label>
<select id="codec-select"> <select id="decoder-select"></select>
<option value="h264">H264</option>
<option value="h265">H265</option>
</select>
</div> </div>
<div class="codec-panel"> <div class="codec-panel">
<label for="encoder-select">硬件编码器:</label> <label for="encoder-select">硬件编码器:</label>
<select id="encoder-select"> <select id="encoder-select"></select>
<option value="software">Software</option>
<option value="neon">NEON</option>
<option value="nvidia">NVIDIA</option>
<option value="intel">Intel</option>
<option value="vaapi">VAAPI</option>
<option value="rkmpp">RKMPP</option>
</select>
</div> </div>
<div id="loading-spinner" class="spinner-container"> <div id="loading-spinner" class="spinner-container">
<div class="spinner"></div> <div class="spinner"></div>

View File

@@ -10,8 +10,10 @@ document.addEventListener('DOMContentLoaded', () => {
const loginPasswordInput = document.getElementById('login-password'); const loginPasswordInput = document.getElementById('login-password');
const loginBtn = document.getElementById('login-btn'); const loginBtn = document.getElementById('login-btn');
const loginError = document.getElementById('login-error'); 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 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 playerOverlay = document.getElementById('player-overlay');
const transcodingOverlay = document.getElementById('transcoding-overlay'); const transcodingOverlay = document.getElementById('transcoding-overlay');
const videoPlayer = document.getElementById('video-player'); const videoPlayer = document.getElementById('video-player');
@@ -89,6 +91,12 @@ document.addEventListener('DOMContentLoaded', () => {
if (playbackSpeed) { if (playbackSpeed) {
videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1; videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1;
} }
if (decoderLabel) {
decoderLabel.textContent = '视频解码器:';
}
if (encoderLabel) {
encoderLabel.textContent = '视频编码器:';
}
const formatBytes = (bytes) => { const formatBytes = (bytes) => {
if (!bytes || bytes === 0) return '0 B'; 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 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 buildStreamUrl = (targetSeconds = null) => {
const codec = codecSelect?.value || 'h264'; const decoder = decoderSelect?.value || 'auto';
const encoder = encoderSelect?.value || 'software'; const encoder = encoderSelect?.value || 'h264_rkmpp';
const streamSessionId = createStreamSessionId(); 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) { if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) {
streamUrl += `&ss=${targetSeconds}`; streamUrl += `&ss=${targetSeconds}`;
} }
@@ -584,6 +611,8 @@ document.addEventListener('DOMContentLoaded', () => {
topBanner.textContent = title; topBanner.textContent = title;
topBanner.classList.remove('hidden'); topBanner.classList.remove('hidden');
document.title = title; document.title = title;
populateSelect(decoderSelect, data.videoDecoders || [], data.defaultVideoDecoder || 'auto');
populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp');
} catch (err) { } catch (err) {
console.error('Config load failed:', err); console.error('Config load failed:', err);
} }
@@ -908,8 +937,6 @@ document.addEventListener('DOMContentLoaded', () => {
transcodingOverlay.classList.remove('hidden'); transcodingOverlay.classList.remove('hidden');
try { try {
const codec = codecSelect?.value || 'h264';
const encoder = encoderSelect?.value || 'software';
if (!selectedBucket) throw new Error('No bucket selected'); if (!selectedBucket) throw new Error('No bucket selected');
const streamUrl = buildStreamUrl(); const streamUrl = buildStreamUrl();
videoPlayer.src = streamUrl; videoPlayer.src = streamUrl;

View File

@@ -15,7 +15,15 @@ const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || process.env.LISTEN_ADDRESS || '0.0.0.0'; const HOST = process.env.HOST || process.env.LISTEN_ADDRESS || '0.0.0.0';
const server = http.createServer(app); 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(cors());
app.use(express.json()); app.use(express.json());
@@ -63,6 +71,34 @@ const progressMap = {};
const transcodeProcesses = new Map(); const transcodeProcesses = new Map();
const wsSubscriptions = 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 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)}`; 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 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 getRkmppDecoderName = (metadata) => {
const videoStream = (metadata?.streams || []).find((stream) => stream.codec_type === 'video'); 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) => { app.get('/api/config', (req, res) => {
const title = process.env.APP_TITLE || 'S3 Media Transcoder'; 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) => { 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) => { app.get('/api/stream', async (req, res) => {
const bucket = req.query.bucket; const bucket = req.query.bucket;
const key = req.query.key; const key = req.query.key;
const codec = req.query.codec; const requestedDecoder = typeof req.query.decoder === 'string' ? req.query.decoder.trim() : 'auto';
const encoder = req.query.encoder; const requestedEncoder = typeof req.query.encoder === 'string' ? req.query.encoder.trim() : 'h264_rkmpp';
const startSeconds = parseFloat(req.query.ss) || 0; const startSeconds = parseFloat(req.query.ss) || 0;
const streamSessionId = typeof req.query.streamSessionId === 'string' && req.query.streamSessionId.trim() const streamSessionId = typeof req.query.streamSessionId === 'string' && req.query.streamSessionId.trim()
? 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' }); return res.status(400).json({ error: 'Video key is required' });
} }
const safeCodec = codec === 'h265' ? 'h265' : 'h264'; const videoEncoder = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
const safeEncoder = ['nvidia', 'intel', 'vaapi', 'rkmpp', 'neon'].includes(encoder) ? encoder : 'software'; const requestedVideoDecoder = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
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 safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
const progressKey = safeKeySegments.join('/'); const progressKey = safeKeySegments.join('/');
@@ -543,7 +581,7 @@ app.get('/api/stream', async (req, res) => {
let ffmpegCommand = null; let ffmpegCommand = null;
const startStream = (encoderName) => { const startStream = (encoderName, decoderName) => {
const streamingOptions = createFfmpegOptions(encoderName).concat(getSeekFriendlyOutputOptions(encoderName, sourceMetadata)); const streamingOptions = createFfmpegOptions(encoderName).concat(getSeekFriendlyOutputOptions(encoderName, sourceMetadata));
ffmpegCommand = ffmpeg() ffmpegCommand = ffmpeg()
.input(tmpInputPath) .input(tmpInputPath)
@@ -552,10 +590,6 @@ app.get('/api/stream', async (req, res) => {
.outputOptions(streamingOptions) .outputOptions(streamingOptions)
.format('mp4'); .format('mp4');
if (isRkmppCodec(encoderName) && typeof ffmpegCommand.setFfmpegPath === 'function') {
ffmpegCommand.setFfmpegPath(RKMPP_FFMPEG_PATH);
}
if (startSeconds > 0) { if (startSeconds > 0) {
if (typeof ffmpegCommand.seekInput === 'function') { if (typeof ffmpegCommand.seekInput === 'function') {
ffmpegCommand.seekInput(startSeconds); ffmpegCommand.seekInput(startSeconds);
@@ -564,15 +598,18 @@ app.get('/api/stream', async (req, res) => {
} }
} }
if (/_vaapi$/.test(encoderName)) { if (isVaapiCodec(encoderName)) {
ffmpegCommand ffmpegCommand
.inputOptions(['-vaapi_device', '/dev/dri/renderD128']) .inputOptions(['-vaapi_device', '/dev/dri/renderD128'])
.videoFilters('format=nv12,hwupload'); .videoFilters('format=nv12,hwupload');
} else if (isRkmppCodec(encoderName)) { }
const rkmppDecoder = getRkmppDecoderName(sourceMetadata);
if (rkmppDecoder) { const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName)
ffmpegCommand.inputOptions(['-c:v', rkmppDecoder]); ? getRkmppDecoderName(sourceMetadata)
} : decoderName;
if (resolvedDecoderName && resolvedDecoderName !== 'auto') {
ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]);
} }
transcodeProcesses.set(progressKey, { command: ffmpegCommand, streamSessionId }); transcodeProcesses.set(progressKey, { command: ffmpegCommand, streamSessionId });
@@ -664,7 +701,7 @@ app.get('/api/stream', async (req, res) => {
} }
}); });
startStream(videoCodec); startStream(videoEncoder, requestedVideoDecoder);
} catch (error) { } catch (error) {
console.error('Error in stream:', error); console.error('Error in stream:', error);
if (!res.headersSent) { if (!res.headersSent) {