From 8b936130df8e5fafae3398548f56a0583a2f7151 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Fri, 3 Apr 2026 17:53:05 +0800 Subject: [PATCH 01/15] add support for rkmpp --- README.md | 1 + public/index.html | 1 + server.js | 19 ++++++++++++------- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6eb973c..958a9f9 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ To properly test and run this project, you will need to prepare your environment 1. **Install Node.js & FFmpeg**: - 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. 2. **AWS S3 / MinIO Configuration**: - Modify the `.env` file (copy from `.env.example`). diff --git a/public/index.html b/public/index.html index 2809741..92b9b7e 100644 --- a/public/index.html +++ b/public/index.html @@ -68,6 +68,7 @@ +
diff --git a/server.js b/server.js index f5a5417..984b5a1 100644 --- a/server.js +++ b/server.js @@ -95,15 +95,17 @@ const broadcastWs = (key, payload) => { }; const createFfmpegOptions = (encoderName) => { - const options = ['-preset fast']; + const options = []; if (encoderName === 'libx264' || encoderName === 'libx265') { - options.push('-crf', '23', '-threads', '0'); + options.push('-preset', 'fast', '-crf', '23', '-threads', '0'); } else if (/_nvenc$/.test(encoderName)) { - options.push('-rc:v', 'vbr_hq', '-cq', '19'); + options.push('-preset', 'fast', '-rc:v', 'vbr_hq', '-cq', '19'); } else if (/_qsv$/.test(encoderName)) { - options.push('-global_quality', '23'); + options.push('-preset', 'fast', '-global_quality', '23'); } else if (/_vaapi$/.test(encoderName)) { options.push('-qp', '23'); + } else if (/_rkmpp$/.test(encoderName)) { + options.push('-qp_init', '23', '-pix_fmt', 'nv12'); } return options; }; @@ -146,6 +148,8 @@ const getSeekFriendlyOutputOptions = (encoderName, metadata) => { options.push('-forced-idr', '1', '-force_key_frames', 'expr:gte(t,n_forced*2)'); } else if (/_qsv$/.test(encoderName)) { options.push('-idr_interval', '1'); + } else if (/_rkmpp$/.test(encoderName)) { + options.push('-force_key_frames', 'expr:gte(t,n_forced*2)'); } return options; @@ -153,7 +157,7 @@ const getSeekFriendlyOutputOptions = (encoderName, metadata) => { const shouldRetryWithSoftware = (message) => { if (!message) return false; - return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument/i.test(message); + return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument|mpp_create|rkmpp/i.test(message); }; const probeFile = (filePath) => { @@ -392,13 +396,14 @@ app.get('/api/stream', async (req, res) => { } const safeCodec = codec === 'h265' ? 'h265' : 'h264'; - const safeEncoder = ['nvidia', 'intel', 'vaapi', 'neon'].includes(encoder) ? encoder : 'software'; + 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' } + vaapi: { h264: 'h264_vaapi', h265: 'hevc_vaapi' }, + rkmpp: { h264: 'h264_rkmpp', h265: 'hevc_rkmpp' } }; const videoCodec = codecMap[safeEncoder][safeCodec]; -- 2.47.3 From 2288370976c79722546759fbbdbf337ef7cc8a5b Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Fri, 3 Apr 2026 18:06:48 +0800 Subject: [PATCH 02/15] =?UTF-8?q?=E4=BF=AE=E6=94=B9rkmpp=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E7=9A=84ffmpeg=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + server.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/README.md b/README.md index 958a9f9..de1335b 100644 --- a/README.md +++ b/README.md @@ -6,6 +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`. 2. **AWS S3 / MinIO Configuration**: - Modify the `.env` file (copy from `.env.example`). diff --git a/server.js b/server.js index 984b5a1..3c2848e 100644 --- a/server.js +++ b/server.js @@ -15,6 +15,7 @@ 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'; app.use(cors()); app.use(express.json()); @@ -110,6 +111,26 @@ const createFfmpegOptions = (encoderName) => { return options; }; +const isRkmppCodec = (codecName) => /_rkmpp$/.test(codecName); + +const getRkmppDecoderName = (metadata) => { + const videoStream = (metadata?.streams || []).find((stream) => stream.codec_type === 'video'); + const codecName = (videoStream?.codec_name || '').toLowerCase(); + const decoderMap = { + av1: 'av1_rkmpp', + h263: 'h263_rkmpp', + h264: 'h264_rkmpp', + hevc: 'hevc_rkmpp', + mjpeg: 'mjpeg_rkmpp', + mpeg1video: 'mpeg1_rkmpp', + mpeg2video: 'mpeg2_rkmpp', + mpeg4: 'mpeg4_rkmpp', + vp8: 'vp8_rkmpp', + vp9: 'vp9_rkmpp' + }; + return decoderMap[codecName] || null; +}; + const parseFpsValue = (fpsText) => { if (typeof fpsText !== 'string' || !fpsText.trim()) { return 0; @@ -531,6 +552,10 @@ 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); @@ -543,6 +568,11 @@ app.get('/api/stream', async (req, res) => { 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]); + } } transcodeProcesses.set(progressKey, { command: ffmpegCommand, streamSessionId }); -- 2.47.3 From d5b70833e6e7106755a3c90eb85f79f25bfd4c88 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Fri, 3 Apr 2026 21:31:38 +0800 Subject: [PATCH 03/15] =?UTF-8?q?=E6=B7=BB=E5=8A=A0RKMPP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- public/index.html | 16 ++------- public/js/main.js | 39 ++++++++++++++++---- server.js | 91 +++++++++++++++++++++++++++++++++-------------- 4 files changed, 101 insertions(+), 47 deletions(-) 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) { -- 2.47.3 From cd866b05df01773506e2e0921c30f787a5f54743 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Fri, 3 Apr 2026 21:55:47 +0800 Subject: [PATCH 04/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dseeking=E5=90=8E?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=8B=89=E5=8A=A8=E8=BF=9B=E5=BA=A6=E6=9D=A1?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/main.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/public/js/main.js b/public/js/main.js index 0823d83..747dca6 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -585,9 +585,11 @@ document.addEventListener('DOMContentLoaded', () => { const onCanPlay = () => { if (seekingOverlay) seekingOverlay.classList.add('hidden'); + isStreamActive = true; lastAbsolutePlaybackTime = targetSeconds; videoPlayer.play().catch(() => {}); updatePlayControls(); + schedulePlaybackChromeHide(); videoPlayer.removeEventListener('canplay', onCanPlay); }; videoPlayer.addEventListener('canplay', onCanPlay, { once: true }); @@ -595,6 +597,7 @@ document.addEventListener('DOMContentLoaded', () => { // Timeout fallback: hide seeking overlay after 8s even if canplay doesn't fire pendingSeekTimeout = setTimeout(() => { if (seekingOverlay) seekingOverlay.classList.add('hidden'); + isStreamActive = true; pendingSeekTimeout = null; }, 8000); }; -- 2.47.3 From f3733ef8efe6dde9b4014262ec8f55929a5bf487 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 00:49:54 +0800 Subject: [PATCH 05/15] =?UTF-8?q?=E4=BD=BF=E7=94=A8=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.html | 48 +---------------------------------------------- public/js/main.js | 11 +---------- 2 files changed, 2 insertions(+), 57 deletions(-) diff --git a/public/index.html b/public/index.html index 60ccb72..32a5ac6 100644 --- a/public/index.html +++ b/public/index.html @@ -110,58 +110,12 @@
- + - + diff --git a/public/js/main.js b/public/js/main.js index 26671f2..bdcfbb4 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -61,6 +61,7 @@ document.addEventListener('DOMContentLoaded', () => { const seekTotalTime = document.getElementById('seek-total-time'); const seekingOverlay = document.getElementById('seeking-overlay'); + let hlsInstance = null; let currentPollInterval = null; let selectedBucket = null; let selectedKey = null; @@ -141,14 +142,10 @@ document.addEventListener('DOMContentLoaded', () => { } }; - const buildStreamUrl = (targetSeconds = null) => { + const buildHlsPlaylistUrl = () => { const decoder = decoderSelect?.value || 'auto'; const encoder = encoderSelect?.value || 'h264_rkmpp'; - const streamSessionId = createStreamSessionId(); - 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}`; - } + let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`; if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`; if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`; return streamUrl; @@ -429,25 +426,7 @@ document.addEventListener('DOMContentLoaded', () => { - // Browsers usually cannot seek inside a live fragmented MP4 stream. - // When the user drags the native video controls, remap that action to our server-side seek flow. - videoPlayer.addEventListener('seeking', () => { - if (internalSeeking || !isStreamActive || videoDuration <= 0) return; - const requestedAbsoluteTime = Math.max(0, Math.min(seekOffset + (videoPlayer.currentTime || 0), videoDuration - 0.5)); - const drift = Math.abs(requestedAbsoluteTime - lastAbsolutePlaybackTime); - - if (drift < 1) { - return; - } - - internalSeeking = true; - setPlaybackStatus('Seeking', 'seeking'); - seekToTime(requestedAbsoluteTime); - setTimeout(() => { - internalSeeking = false; - }, 500); - }); // Seek bar interaction: click or drag const getSeekRatio = (e) => { @@ -549,49 +528,7 @@ document.addEventListener('DOMContentLoaded', () => { }); } - const seekToTime = (targetSeconds) => { - if (!selectedKey || !selectedBucket || videoDuration <= 0) return; - targetSeconds = Math.max(0, Math.min(targetSeconds, videoDuration - 0.5)); - seekOffset = targetSeconds; - isStreamActive = false; - lastAbsolutePlaybackTime = targetSeconds; - updateSeekBarPosition(targetSeconds); - // Show seeking indicator - if (seekingOverlay) seekingOverlay.classList.remove('hidden'); - setPlaybackStatus('Seeking', 'seeking'); - revealPlaybackChrome(); - if (pendingSeekTimeout) { - clearTimeout(pendingSeekTimeout); - pendingSeekTimeout = null; - } - - // Build new stream URL with ss parameter - const streamUrl = buildStreamUrl(targetSeconds); - - // Changing src automatically aborts the previous HTTP request, - // which triggers res.on('close') on the server, killing the old ffmpeg process - videoPlayer.src = streamUrl; - videoPlayer.load(); - - const onCanPlay = () => { - if (seekingOverlay) seekingOverlay.classList.add('hidden'); - isStreamActive = true; - lastAbsolutePlaybackTime = targetSeconds; - videoPlayer.play().catch(() => {}); - updatePlayControls(); - schedulePlaybackChromeHide(); - videoPlayer.removeEventListener('canplay', onCanPlay); - }; - videoPlayer.addEventListener('canplay', onCanPlay, { once: true }); - - // Timeout fallback: hide seeking overlay after 8s even if canplay doesn't fire - pendingSeekTimeout = setTimeout(() => { - if (seekingOverlay) seekingOverlay.classList.add('hidden'); - isStreamActive = true; - pendingSeekTimeout = null; - }, 8000); - }; // --- End custom seek bar --- @@ -875,6 +812,10 @@ document.addEventListener('DOMContentLoaded', () => { videoPlayer.classList.add('hidden'); videoPlayer.pause(); videoPlayer.removeAttribute('src'); + if (hlsInstance) { + hlsInstance.destroy(); + hlsInstance = null; + } videoPlayer.load(); isStreamActive = false; videoDuration = 0; @@ -932,22 +873,57 @@ document.addEventListener('DOMContentLoaded', () => { try { if (!selectedBucket) throw new Error('No bucket selected'); - const streamUrl = buildStreamUrl(); - videoPlayer.src = streamUrl; - videoPlayer.load(); - videoPlayer.addEventListener('loadedmetadata', () => { - transcodingOverlay.classList.add('hidden'); - videoPlayer.classList.remove('hidden'); - isStreamActive = true; - lastAbsolutePlaybackTime = seekOffset; - showSeekBar(); - showCustomControls(); - updateSeekBarPosition(seekOffset); - updatePlayControls(); - updateVolumeControls(); - updateFullscreenControls(); - schedulePlaybackChromeHide(); - }, { once: true }); + const streamUrl = buildHlsPlaylistUrl(); + if (hlsInstance) { + hlsInstance.destroy(); + hlsInstance = null; + } + if (window.Hls && Hls.isSupported()) { + hlsInstance = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 }); + hlsInstance.loadSource(streamUrl); + hlsInstance.attachMedia(videoPlayer); + hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + transcodingOverlay.classList.add('hidden'); + videoPlayer.classList.remove('hidden'); + isStreamActive = true; + videoPlayer.play().catch(()=>{}); + showSeekBar(); + showCustomControls(); + updatePlayControls(); + updateVolumeControls(); + updateFullscreenControls(); + schedulePlaybackChromeHide(); + }); + hlsInstance.on(Hls.Events.ERROR, function (event, data) { + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + hlsInstance.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + hlsInstance.recoverMediaError(); + break; + default: + hlsInstance.destroy(); + break; + } + } + }); + } else if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) { + videoPlayer.src = streamUrl; + videoPlayer.addEventListener('loadedmetadata', () => { + transcodingOverlay.classList.add('hidden'); + videoPlayer.classList.remove('hidden'); + isStreamActive = true; + videoPlayer.play().catch(()=>{}); + showSeekBar(); + showCustomControls(); + updatePlayControls(); + updateVolumeControls(); + updateFullscreenControls(); + schedulePlaybackChromeHide(); + }, { once: true }); + } } catch (err) { console.error(err); transcodingOverlay.innerHTML = `

Transcode Failed: ${err.message}

`; @@ -969,6 +945,10 @@ document.addEventListener('DOMContentLoaded', () => { if (!res.ok) throw new Error(data.error || 'Failed to stop transcode'); handleProgress({ status: 'cancelled', percent: 0, details: 'Transcode stopped' }); + if (hlsInstance) { + hlsInstance.destroy(); + hlsInstance = null; + } isStreamActive = false; videoPlayer.pause(); videoPlayer.removeAttribute('src'); @@ -1091,34 +1071,7 @@ document.addEventListener('DOMContentLoaded', () => { document.addEventListener('fullscreenchange', updateFullscreenControls); - document.addEventListener('keydown', (event) => { - if (!isStreamActive) return; - const tagName = event.target?.tagName; - if (tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA' || tagName === 'BUTTON') return; - if (event.code === 'Space') { - event.preventDefault(); - if (videoPlayer.paused) { - videoPlayer.play().catch(() => {}); - } else { - videoPlayer.pause(); - } - revealPlaybackChrome(); - schedulePlaybackChromeHide(); - return; - } - - if (event.code === 'ArrowRight') { - event.preventDefault(); - seekToTime((seekOffset + (videoPlayer.currentTime || 0)) + 5); - return; - } - - if (event.code === 'ArrowLeft') { - event.preventDefault(); - seekToTime((seekOffset + (videoPlayer.currentTime || 0)) - 5); - } - }); updatePlayControls(); updateVolumeControls(); diff --git a/server.js b/server.js index 0162c67..1fe4359 100644 --- a/server.js +++ b/server.js @@ -446,6 +446,162 @@ app.post('/api/stop-transcode', (req, res) => { } }); +const HLS_SEGMENT_TIME = 6; +const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => { + const start = Date.now(); + const segPath = path.join(hlsDir, `segment_${segIndex}.ts`); + const m3u8Path = path.join(hlsDir, `temp.m3u8`); + + while (Date.now() - start < timeoutMs) { + if (fs.existsSync(m3u8Path)) { + const m3u8Content = fs.readFileSync(m3u8Path, 'utf8'); + if (m3u8Content.includes(`segment_${segIndex}.ts`)) { + return true; + } + if (m3u8Content.includes(`#EXT-X-ENDLIST`)) { + if (fs.existsSync(segPath)) return true; + return false; + } + } + await new Promise(r => setTimeout(r, 200)); + } + return false; +}; + +app.get('/api/hls/playlist.m3u8', async (req, res) => { + const bucket = req.query.bucket; + const key = req.query.key; + if (!bucket || !key) return res.status(400).send('Bad Request'); + + const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); + const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); + const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); + + const auth = extractS3Credentials(req); + const s3Client = createS3Client(auth); + let totalBytes = 0; + + if (!fs.existsSync(tmpInputPath)) { + try { + const command = new GetObjectCommand({ Bucket: bucket, Key: key }); + const response = await s3Client.send(command); + totalBytes = response.ContentLength; + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(tmpInputPath); + response.Body.pipe(writeStream); + writeStream.on('error', reject); + writeStream.on('finish', resolve); + }); + } catch(err) { + return res.status(500).send('S3 Download Failed'); + } + } + + let duration = 0; + try { + const metadata = await probeFile(tmpInputPath); + duration = parseFloat(metadata.format?.duration || 0); + } catch(err) {} + + if (duration <= 0) duration = 3600; + + const totalSegments = Math.ceil(duration / HLS_SEGMENT_TIME); + + let m3u8 = `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${HLS_SEGMENT_TIME}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n`; + for (let i = 0; i < totalSegments; i++) { + let segDur = HLS_SEGMENT_TIME; + if (i === totalSegments - 1 && duration % HLS_SEGMENT_TIME !== 0) { + segDur = (duration % HLS_SEGMENT_TIME) || HLS_SEGMENT_TIME; + } + m3u8 += `#EXTINF:${segDur.toFixed(6)},\nsegment.ts?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}&seg=${i}&encoder=${req.query.encoder||'h264_rkmpp'}&decoder=${req.query.decoder||'auto'}\n`; + } + m3u8 += `#EXT-X-ENDLIST\n`; + + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + res.setHeader('Cache-Control', 'no-cache'); + res.send(m3u8); +}); + +const hlsProcesses = new Map(); + +app.get('/api/hls/segment.ts', async (req, res) => { + const bucket = req.query.bucket; + const key = req.query.key; + const seg = parseInt(req.query.seg || '0'); + const requestedEncoder = req.query.encoder || 'h264_rkmpp'; + const requestedDecoder = req.query.decoder || 'auto'; + + if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request'); + + const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); + const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); + const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); + + const progressKey = safeKeySegments.join('/'); + const hlsDir = path.join(os.tmpdir(), `hls-${safeBucket}-${progressKey}`); + if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true }); + + const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`); + let currentProcess = hlsProcesses.get(progressKey); + const needsNewProcess = !currentProcess || (!fs.existsSync(targetSegPath) && Math.abs((currentProcess.currentSeg || 0) - seg) > 2); + + if (needsNewProcess) { + if (currentProcess && currentProcess.command) { + try { currentProcess.command.kill('SIGKILL'); } catch(e){} + } + + const startTime = Math.max(0, seg * HLS_SEGMENT_TIME); + + let sourceMetadata = null; + try { sourceMetadata = await probeFile(tmpInputPath); } catch(e){} + + const encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp'; + const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto'; + + const m3u8Path = path.join(hlsDir, `temp.m3u8`); + if (fs.existsSync(m3u8Path)) fs.unlinkSync(m3u8Path); + + const ffmpegCommand = ffmpeg().input(tmpInputPath); + if (startTime > 0) ffmpegCommand.seekInput(startTime); + + ffmpegCommand.videoCodec(encoderName).audioCodec('aac'); + + if (isVaapiCodec(encoderName)) { + ffmpegCommand.inputOptions(['-vaapi_device', '/dev/dri/renderD128']).videoFilters('format=nv12,hwupload'); + } + const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName) ? getRkmppDecoderName(sourceMetadata) : decoderName; + if (resolvedDecoderName && resolvedDecoderName !== 'auto') ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]); + + const segmentFilename = path.join(hlsDir, `segment_%d.ts`); + const hlsOptions = createFfmpegOptions(encoderName).concat([ + '-f', 'hls', + '-hls_time', HLS_SEGMENT_TIME.toString(), + '-hls_list_size', '0', + '-hls_segment_filename', segmentFilename, + '-start_number', seg.toString() + ]); + + ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path); + ffmpegCommand.on('error', (err) => { + console.error('HLS FFmpeg Error:', err.message); + }); + + ffmpegCommand.run(); + currentProcess = { command: ffmpegCommand, currentSeg: seg }; + hlsProcesses.set(progressKey, currentProcess); + } + + const ready = await waitForSegment(hlsDir, seg); + if (!ready) { + return res.status(500).send('Segment generation timeout'); + } + + if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg); + + res.setHeader('Content-Type', 'video/MP2T'); + res.sendFile(targetSegPath); +}); + app.get('/api/stream', async (req, res) => { const bucket = req.query.bucket; const key = req.query.key; -- 2.47.3 From fee1576e96a3e8c362c10bceb9cfd13daa074ab4 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 01:04:36 +0800 Subject: [PATCH 07/15] =?UTF-8?q?=E6=B7=BB=E5=8A=A0ffmpeg=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/server.js b/server.js index 1fe4359..9ac3d8d 100644 --- a/server.js +++ b/server.js @@ -586,6 +586,39 @@ app.get('/api/hls/segment.ts', async (req, res) => { console.error('HLS FFmpeg Error:', err.message); }); + ffmpegCommand.on('progress', (progress) => { + const timemarkSeconds = parseTimemarkToSeconds(progress.timemark || '0'); + const absoluteSeconds = startTime + (isFinite(timemarkSeconds) ? timemarkSeconds : 0); + const totalDuration = parseFloat(sourceMetadata?.format?.duration || 0); + + let percent = 0; + if (totalDuration > 0) { + percent = Math.min(Math.max(Math.round((absoluteSeconds / totalDuration) * 100), 0), 100); + } + + const progressState = { + status: 'transcoding', + percent, + frame: progress.frames || null, + fps: progress.currentFps || null, + bitrate: progress.currentKbps || null, + timemark: progress.timemark || null, + absoluteSeconds, + duration: totalDuration || null, + startSeconds: startTime, + details: `处理进度 ${percent}%`, + mp4Url: null + }; + progressMap[progressKey] = progressState; + broadcastWs(progressKey, { type: 'progress', key, progress: progressState }); + + console.log(`[FFmpeg] ${progressKey} | ${progress.timemark} | ${progress.currentFps}fps | ${progress.currentKbps}kbps | ${percent}%`); + }); + + ffmpegCommand.on('end', () => { + console.log(`[FFmpeg] ${progressKey} HLS transcode completed.`); + }); + ffmpegCommand.run(); currentProcess = { command: ffmpegCommand, currentSeg: seg }; hlsProcesses.set(progressKey, currentProcess); -- 2.47.3 From 699f7af18d25f9bd34ee5eeea166947dd7e74942 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 01:08:27 +0800 Subject: [PATCH 08/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/server.js b/server.js index 9ac3d8d..a92b075 100644 --- a/server.js +++ b/server.js @@ -543,7 +543,24 @@ app.get('/api/hls/segment.ts', async (req, res) => { const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`); let currentProcess = hlsProcesses.get(progressKey); - const needsNewProcess = !currentProcess || (!fs.existsSync(targetSegPath) && Math.abs((currentProcess.currentSeg || 0) - seg) > 2); + + const checkIsCachedAndCompleted = () => { + if (!fs.existsSync(targetSegPath)) return false; + const m3u8Path = path.join(hlsDir, `temp.m3u8`); + if (fs.existsSync(m3u8Path) && fs.readFileSync(m3u8Path, 'utf8').includes(`segment_${seg}.ts`)) return true; + if (currentProcess && Math.abs((currentProcess.currentSeg || 0) - seg) > 3) return true; + // If there's no active process, any existing file is from a past complete run + if (!currentProcess) return true; + return false; + }; + + if (checkIsCachedAndCompleted()) { + if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg); + res.setHeader('Content-Type', 'video/MP2T'); + return res.sendFile(targetSegPath); + } + + const needsNewProcess = !currentProcess || (!fs.existsSync(targetSegPath) && (seg < (currentProcess.currentSeg || 0) || seg > (currentProcess.currentSeg || 0) + 4)); if (needsNewProcess) { if (currentProcess && currentProcess.command) { @@ -580,6 +597,7 @@ app.get('/api/hls/segment.ts', async (req, res) => { '-hls_segment_filename', segmentFilename, '-start_number', seg.toString() ]); + if (startTime > 0) hlsOptions.push('-output_ts_offset', startTime.toString()); ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path); ffmpegCommand.on('error', (err) => { -- 2.47.3 From 80b0a8d7975ca701ff31d9728c2bee7622cb5e8d Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 01:12:49 +0800 Subject: [PATCH 09/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8DPTS=E6=BC=82=E7=A7=BB?= =?UTF-8?q?=E7=BB=BC=E5=90=88=E7=97=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server.js b/server.js index a92b075..84f52de 100644 --- a/server.js +++ b/server.js @@ -595,9 +595,12 @@ app.get('/api/hls/segment.ts', async (req, res) => { '-hls_time', HLS_SEGMENT_TIME.toString(), '-hls_list_size', '0', '-hls_segment_filename', segmentFilename, - '-start_number', seg.toString() + '-start_number', seg.toString(), + '-copyts', + '-avoid_negative_ts', 'disabled', + '-muxdelay', '0', + '-muxpreload', '0' ]); - if (startTime > 0) hlsOptions.push('-output_ts_offset', startTime.toString()); ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path); ffmpegCommand.on('error', (err) => { -- 2.47.3 From 8044b6cec20869b54b8cb08333817d2e9af846a9 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 01:19:12 +0800 Subject: [PATCH 10/15] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=B8=8D=E6=98=BE=E7=A4=BA=E4=B8=8B=E8=BD=BD=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/server.js b/server.js index 84f52de..edc7eb0 100644 --- a/server.js +++ b/server.js @@ -480,21 +480,83 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => { const auth = extractS3Credentials(req); const s3Client = createS3Client(auth); let totalBytes = 0; + const progressKey = getProgressKey(key); + const streamSessionId = createStreamSessionId(); + let downloadedBytes = 0; if (!fs.existsSync(tmpInputPath)) { try { const command = new GetObjectCommand({ Bucket: bucket, Key: key }); const response = await s3Client.send(command); totalBytes = response.ContentLength; + const s3Stream = response.Body; + + progressMap[progressKey] = { + status: 'downloading', + percent: 0, + downloadedBytes: 0, + totalBytes, + streamSessionId, + details: 'Downloading full source before streaming...', + mp4Url: null + }; + broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); + await new Promise((resolve, reject) => { const writeStream = fs.createWriteStream(tmpInputPath); - response.Body.pipe(writeStream); + + s3Stream.on('data', (chunk) => { + downloadedBytes += chunk.length; + const percent = totalBytes ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) : 0; + + const downloadState = { + status: 'downloading', + percent, + downloadedBytes, + totalBytes, + streamSessionId, + details: totalBytes ? `Downloading source ${percent}%` : 'Downloading source...', + mp4Url: null + }; + progressMap[progressKey] = downloadState; + broadcastWs(progressKey, { type: 'progress', key, progress: downloadState }); + }); + + s3Stream.on('error', reject); writeStream.on('error', reject); writeStream.on('finish', resolve); + s3Stream.pipe(writeStream); }); + + progressMap[progressKey] = { + status: 'downloaded', + percent: 100, + downloadedBytes, + totalBytes, + streamSessionId, + details: 'Source download complete, parsing for HLS...', + mp4Url: null + }; + broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); + } catch(err) { + console.error('S3 Download Failed:', err); return res.status(500).send('S3 Download Failed'); } + } else { + const stats = fs.statSync(tmpInputPath); + totalBytes = stats.size; + downloadedBytes = totalBytes; + progressMap[progressKey] = { + status: 'downloaded', + percent: 100, + downloadedBytes, + totalBytes, + streamSessionId, + details: 'Source already downloaded locally, parsing for HLS...', + mp4Url: null + }; + broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); } let duration = 0; -- 2.47.3 From 2b9e1b3463dabfeed3463970a90bfa6fefc40c21 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 01:23:36 +0800 Subject: [PATCH 11/15] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/server.js b/server.js index edc7eb0..ad48e12 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,10 @@ const { S3Client, ListBucketsCommand, ListObjectsV2Command, GetObjectCommand } = dotenv.config(); +const CACHE_DIR = path.join(__dirname, 'cache'); +if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); +} const app = express(); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || process.env.LISTEN_ADDRESS || '0.0.0.0'; @@ -321,7 +325,7 @@ wss.on('connection', (ws) => { const clearDownloadCache = () => { - const tmpDir = os.tmpdir(); + const tmpDir = CACHE_DIR; try { if (!fs.existsSync(tmpDir)) return; const files = fs.readdirSync(tmpDir); @@ -329,6 +333,9 @@ const clearDownloadCache = () => { if (file.startsWith('s3-input-') && file.endsWith('.tmp')) { const filePath = path.join(tmpDir, file); fs.rmSync(filePath, { force: true }); + } else if (file.startsWith('hls-')) { + const filePath = path.join(tmpDir, file); + fs.rmSync(filePath, { recursive: true, force: true }); } } } catch (err) { @@ -475,7 +482,7 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => { const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); - const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); + const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); const auth = extractS3Credentials(req); const s3Client = createS3Client(auth); @@ -597,10 +604,10 @@ app.get('/api/hls/segment.ts', async (req, res) => { const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); - const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); + const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); const progressKey = safeKeySegments.join('/'); - const hlsDir = path.join(os.tmpdir(), `hls-${safeBucket}-${progressKey}`); + const hlsDir = path.join(CACHE_DIR, `hls-${safeBucket}-${progressKey}`); if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true }); const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`); @@ -741,7 +748,7 @@ app.get('/api/stream', async (req, res) => { const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); const progressKey = safeKeySegments.join('/'); const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); - const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); + const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); const cacheExists = fs.existsSync(tmpInputPath); const auth = extractS3Credentials(req); -- 2.47.3 From aa49d2d8d8fbe7e3ea57a11f2529308527b22918 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 11:24:37 +0800 Subject: [PATCH 12/15] =?UTF-8?q?=E6=96=B0=E5=A2=9Ecss=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/index.html b/public/index.html index 67235fc..20abdc1 100644 --- a/public/index.html +++ b/public/index.html @@ -8,7 +8,7 @@ - +
@@ -110,7 +110,7 @@
- + - + -- 2.47.3 From 65e71c5329db1c16d712fd66c2a1310d3bfab18e Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 11:34:06 +0800 Subject: [PATCH 13/15] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E5=99=A8=E5=91=BD=E5=90=8D=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.html | 6 +----- public/js/main.js | 13 ++----------- server.js | 27 +++++---------------------- 3 files changed, 8 insertions(+), 38 deletions(-) diff --git a/public/index.html b/public/index.html index 20abdc1..e452647 100644 --- a/public/index.html +++ b/public/index.html @@ -54,11 +54,7 @@
- - -
-
- +
diff --git a/public/js/main.js b/public/js/main.js index bdcfbb4..3da5a0a 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -10,10 +10,7 @@ document.addEventListener('DOMContentLoaded', () => { const loginPasswordInput = document.getElementById('login-password'); const loginBtn = document.getElementById('login-btn'); const loginError = document.getElementById('login-error'); - 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'); @@ -92,12 +89,7 @@ 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'; @@ -143,7 +135,7 @@ document.addEventListener('DOMContentLoaded', () => { }; const buildHlsPlaylistUrl = () => { - const decoder = decoderSelect?.value || 'auto'; + const decoder = 'auto'; const encoder = encoderSelect?.value || 'h264_rkmpp'; let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`; if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`; @@ -542,7 +534,6 @@ 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); diff --git a/server.js b/server.js index ad48e12..1591f5f 100644 --- a/server.js +++ b/server.js @@ -76,31 +76,14 @@ 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)' } + { value: 'h264_rkmpp', label: 'H.264(RKMPP HighSpeed)' }, + { value: 'hevc_rkmpp', label: 'H.265(RKMPP HighSpeed)' }, + { value: 'libx264', label: 'H.264(Software Slow)' }, + { value: 'libx265', label: 'H.265(Software Slow)' } ]; 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)' } + { value: 'auto', label: 'Auto Select Decoder' } ]; const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/'); -- 2.47.3 From a55c0cc30e2b913b331ff0c356e0b4a554c17925 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 11:34:21 +0800 Subject: [PATCH 14/15] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E5=99=A8=E8=A7=84=E5=88=992?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/main.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/js/main.js b/public/js/main.js index 3da5a0a..6966e20 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -877,7 +877,7 @@ document.addEventListener('DOMContentLoaded', () => { transcodingOverlay.classList.add('hidden'); videoPlayer.classList.remove('hidden'); isStreamActive = true; - videoPlayer.play().catch(()=>{}); + videoPlayer.play().catch(() => { }); showSeekBar(); showCustomControls(); updatePlayControls(); @@ -906,7 +906,7 @@ document.addEventListener('DOMContentLoaded', () => { transcodingOverlay.classList.add('hidden'); videoPlayer.classList.remove('hidden'); isStreamActive = true; - videoPlayer.play().catch(()=>{}); + videoPlayer.play().catch(() => { }); showSeekBar(); showCustomControls(); updatePlayControls(); @@ -1001,7 +1001,7 @@ document.addEventListener('DOMContentLoaded', () => { controlPlayToggle.addEventListener('click', () => { if (!isStreamActive) return; if (videoPlayer.paused) { - videoPlayer.play().catch(() => {}); + videoPlayer.play().catch(() => { }); } else { videoPlayer.pause(); } -- 2.47.3 From 1195a16cf1a65b4b08bf5af39333d473f24c3487 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 11:49:38 +0800 Subject: [PATCH 15/15] =?UTF-8?q?=E4=BD=BF=E7=94=A8VALKEY=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E7=99=BB=E5=BD=95=E8=A3=85=E5=94=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++ ecosystem.config.js | 4 +- package.json | 3 +- public/css/style.css | 90 ++++++++++++++++++++++++---- public/index.html | 39 ++++++++++-- public/js/main.js | 102 ++++++++++++++++++++++++++++---- server.js | 138 ++++++++++++++++++++++++++++--------------- 7 files changed, 305 insertions(+), 75 deletions(-) diff --git a/.env.example b/.env.example index ace6996..d33d5d8 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,7 @@ S3_FORCE_PATH_STYLE=true # Application Title APP_TITLE=S3 Media Transcoder + +# Session Cache (Valkey / Redis) +VALKEY_URL=redis://localhost:6379 +VALKEY_DB=0 diff --git a/ecosystem.config.js b/ecosystem.config.js index 834cb8d..80a679e 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -17,7 +17,9 @@ module.exports = { S3_BUCKET_ADDRESS: 'https://s3.example.com/your_bucket_name', S3_ENDPOINT: 'http://127.0.0.1:9000', S3_FORCE_PATH_STYLE: 'true', - APP_TITLE: 'S3 Media Transcoder' + APP_TITLE: 'S3 Media Transcoder', + VALKEY_URL: 'redis://localhost:6379', + VALKEY_DB: 0 } } ] diff --git a/package.json b/package.json index 3ffb383..b16f8bf 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "fluent-ffmpeg": "^2.1.3", + "ioredis": "^5.3.0", "ws": "^8.13.0" } -} +} \ No newline at end of file diff --git a/public/css/style.css b/public/css/style.css index a6e8646..b52f419 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -10,6 +10,17 @@ --font-main: 'Inter', sans-serif; } +:root[data-theme="dark"] { + --bg-dark: #0f172a; + --panel-bg: rgba(30, 41, 59, 0.85); + --panel-border: rgba(148, 163, 184, 0.15); + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --accent: #3b82f6; + --accent-hover: #60a5fa; + --accent-glow: rgba(59, 130, 246, 0.35); +} + * { margin: 0; padding: 0; @@ -109,7 +120,7 @@ header p { align-items: flex-start; } -.dashboard > * { +.dashboard>* { min-width: 0; } @@ -247,10 +258,37 @@ header p { margin-bottom: 1.5rem; color: var(--text-primary); font-size: 1.25rem; - font-weight: 700; display: flex; align-items: center; - justify-content: flex-start; + justify-content: space-between; +} + +.banner-title { + font-weight: 700; +} + +.user-controls { + display: flex; + align-items: center; + gap: 1.5rem; + font-size: 0.95rem; + font-weight: 500; +} + +.user-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +#theme-selector { + background: var(--panel-bg); + color: var(--text-primary); + border: 1px solid var(--panel-border); + padding: 0.35rem 0.5rem; + border-radius: 6px; + font-size: 0.9rem; + outline: none; } .top-banner.hidden { @@ -699,8 +737,15 @@ header p { } @keyframes statusPulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); } - 50% { box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); } + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); + } + + 50% { + box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); + } } .control-btn { @@ -907,8 +952,15 @@ header p { } @keyframes phaseIn { - from { opacity: 0; transform: translateY(12px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .phase-container h3 { @@ -940,8 +992,15 @@ header p { } @keyframes pulse-download { - 0%, 100% { box-shadow: 0 0 0 0 rgba(6, 182, 212, 0.3); } - 50% { box-shadow: 0 0 0 12px rgba(6, 182, 212, 0); } + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(6, 182, 212, 0.3); + } + + 50% { + box-shadow: 0 0 0 12px rgba(6, 182, 212, 0); + } } .phase-icon.transcode-icon { @@ -951,8 +1010,15 @@ header p { } @keyframes pulse-transcode { - 0%, 100% { box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.3); } - 50% { box-shadow: 0 0 0 12px rgba(139, 92, 246, 0); } + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.3); + } + + 50% { + box-shadow: 0 0 0 12px rgba(139, 92, 246, 0); + } } /* Transcode stats */ @@ -1089,4 +1155,4 @@ header p { .control-seek { min-width: 220px; } -} +} \ No newline at end of file diff --git a/public/index.html b/public/index.html index e452647..82de861 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,6 @@ + @@ -10,6 +11,7 @@ +
@@ -34,7 +36,21 @@
- +