From dd0aa998cc4a3b36ad12c0a64f880fff2cf09354 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Fri, 3 Apr 2026 22:12:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=86=85=E5=B5=8C=E5=AD=97?= =?UTF-8?q?=E5=B9=95=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.html | 15 +++- public/js/main.js | 149 +++++++++++++++++++++++++++++++-- server.js | 207 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 318 insertions(+), 53 deletions(-) diff --git a/public/index.html b/public/index.html index 60ccb72..be6b8bc 100644 --- a/public/index.html +++ b/public/index.html @@ -53,10 +53,6 @@ -
- - -
@@ -143,6 +139,17 @@ 00:00:00
+
diff --git a/public/js/main.js b/public/js/main.js index 747dca6..a3734dc 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -10,9 +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'); @@ -28,7 +26,10 @@ document.addEventListener('DOMContentLoaded', () => { const controlPlayToggle = document.getElementById('control-play-toggle'); const controlMuteToggle = document.getElementById('control-mute-toggle'); const controlFullscreenToggle = document.getElementById('control-fullscreen-toggle'); + const controlSubtitleToggle = document.getElementById('control-subtitle-toggle'); const controlSpeedToggle = document.getElementById('control-speed-toggle'); + const subtitleControl = document.getElementById('subtitle-control'); + const subtitleSelect = document.getElementById('subtitle-select'); const volumeSlider = document.getElementById('volume-slider'); const volumeValue = document.getElementById('volume-value'); const playbackStatus = document.getElementById('playback-status'); @@ -84,6 +85,8 @@ document.addEventListener('DOMContentLoaded', () => { let controlsHideTimeout = null; let controlsHovered = false; let controlsPointerReleaseTimeout = null; + let managedSubtitleTracks = []; + let selectedSubtitleTrackId = 'off'; if (videoPlayer) { videoPlayer.controls = false; @@ -91,9 +94,6 @@ document.addEventListener('DOMContentLoaded', () => { if (playbackSpeed) { videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1; } - if (decoderLabel) { - decoderLabel.textContent = '视频解码器:'; - } if (encoderLabel) { encoderLabel.textContent = '视频编码器:'; } @@ -142,10 +142,9 @@ document.addEventListener('DOMContentLoaded', () => { }; const buildStreamUrl = (targetSeconds = null) => { - 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)}`; + let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=auto&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`; if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) { streamUrl += `&ss=${targetSeconds}`; } @@ -204,6 +203,119 @@ document.addEventListener('DOMContentLoaded', () => { } }; + const updateSubtitleControls = () => { + if (!controlSubtitleToggle || !subtitleSelect) return; + const selectedOption = subtitleSelect.selectedOptions?.[0]; + controlSubtitleToggle.textContent = selectedOption ? selectedOption.textContent : 'CC Off'; + }; + + const resetSubtitleTracks = () => { + managedSubtitleTracks.forEach((track) => track.element.remove()); + managedSubtitleTracks = []; + selectedSubtitleTrackId = 'off'; + + if (subtitleSelect) { + subtitleSelect.innerHTML = ''; + const offOption = document.createElement('option'); + offOption.value = 'off'; + offOption.textContent = 'Off'; + subtitleSelect.appendChild(offOption); + subtitleSelect.value = 'off'; + } + + if (subtitleControl) { + subtitleControl.classList.add('hidden'); + } + + updateSubtitleControls(); + }; + + const applySelectedSubtitleTrack = () => { + managedSubtitleTracks.forEach((track) => { + if (track.element.track) { + track.element.track.mode = track.id === selectedSubtitleTrackId ? 'showing' : 'disabled'; + } + }); + updateSubtitleControls(); + }; + + const buildSubtitleUrl = (streamIndex, targetSeconds = 0) => { + let subtitleUrl = `/api/subtitles?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&streamIndex=${encodeURIComponent(streamIndex)}`; + if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) { + subtitleUrl += `&ss=${targetSeconds}`; + } + if (s3Username) subtitleUrl += `&username=${encodeURIComponent(s3Username)}`; + if (s3Password) subtitleUrl += `&password=${encodeURIComponent(s3Password)}`; + return subtitleUrl; + }; + + const loadSubtitleTracks = async (targetSeconds = 0) => { + if (!selectedBucket || !selectedKey || !subtitleSelect) { + resetSubtitleTracks(); + return; + } + + const previousSelection = selectedSubtitleTrackId; + resetSubtitleTracks(); + + try { + let subtitleListUrl = `/api/subtitles?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}`; + if (s3Username) subtitleListUrl += `&username=${encodeURIComponent(s3Username)}`; + if (s3Password) subtitleListUrl += `&password=${encodeURIComponent(s3Password)}`; + + const res = await fetch(subtitleListUrl); + if (!res.ok) { + throw new Error('Failed to load subtitles'); + } + + const data = await res.json(); + const tracks = Array.isArray(data.tracks) ? data.tracks : []; + if (tracks.length === 0) { + return; + } + + tracks.forEach((track) => { + const option = document.createElement('option'); + option.value = String(track.index); + option.textContent = track.label || `Subtitle ${track.index}`; + option.disabled = !track.supported; + subtitleSelect.appendChild(option); + + if (!track.supported) { + return; + } + + const trackElement = document.createElement('track'); + trackElement.kind = 'subtitles'; + trackElement.label = track.label || `Subtitle ${track.index}`; + if (track.language) { + trackElement.srclang = track.language; + } + trackElement.src = buildSubtitleUrl(track.index, targetSeconds); + trackElement.dataset.managedSubtitle = 'true'; + videoPlayer.appendChild(trackElement); + managedSubtitleTracks.push({ + id: String(track.index), + element: trackElement + }); + }); + + if (subtitleControl) { + subtitleControl.classList.remove('hidden'); + } + + const nextSelection = managedSubtitleTracks.some((track) => track.id === previousSelection) + ? previousSelection + : 'off'; + subtitleSelect.value = nextSelection; + selectedSubtitleTrackId = nextSelection; + applySelectedSubtitleTrack(); + } catch (error) { + console.error('Subtitle load failed:', error); + resetSubtitleTracks(); + } + }; + const updateTranscodeProgressBar = (percent = 0) => { if (!seekBarTranscode) return; const safePercent = Math.min(Math.max(Math.round(percent || 0), 0), 100); @@ -600,6 +712,10 @@ document.addEventListener('DOMContentLoaded', () => { isStreamActive = true; pendingSeekTimeout = null; }, 8000); + + loadSubtitleTracks(targetSeconds).catch((error) => { + console.error('Subtitle reload failed after seek:', error); + }); }; // --- End custom seek bar --- @@ -614,7 +730,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); @@ -909,6 +1024,7 @@ document.addEventListener('DOMContentLoaded', () => { playBtn.classList.add('hidden'); } resetPhases(); + resetSubtitleTracks(); selectedKey = key; currentVideoKey = key; @@ -956,6 +1072,9 @@ document.addEventListener('DOMContentLoaded', () => { updateVolumeControls(); updateFullscreenControls(); schedulePlaybackChromeHide(); + loadSubtitleTracks(seekOffset).catch((error) => { + console.error('Subtitle load failed:', error); + }); }, { once: true }); } catch (err) { console.error(err); @@ -984,6 +1103,7 @@ document.addEventListener('DOMContentLoaded', () => { videoPlayer.load(); hideSeekBar(); hideCustomControls(); + resetSubtitleTracks(); setPlaybackStatus('Paused', 'paused'); updatePlayControls(); if (transcodeBtn) { @@ -1032,6 +1152,9 @@ document.addEventListener('DOMContentLoaded', () => { updateVolumeControls(); updateFullscreenControls(); schedulePlaybackChromeHide(); + loadSubtitleTracks(seekOffset).catch((error) => { + console.error('Subtitle load failed:', error); + }); }, { once: true }); }; @@ -1082,6 +1205,15 @@ document.addEventListener('DOMContentLoaded', () => { }); } + if (subtitleSelect) { + subtitleSelect.addEventListener('change', (event) => { + selectedSubtitleTrackId = event.target.value || 'off'; + applySelectedSubtitleTrack(); + revealPlaybackChrome(); + schedulePlaybackChromeHide(); + }); + } + if (controlFullscreenToggle) { controlFullscreenToggle.addEventListener('click', async () => { try { @@ -1133,6 +1265,7 @@ document.addEventListener('DOMContentLoaded', () => { updateVolumeControls(); updateFullscreenControls(); updateSpeedControls(); + resetSubtitleTracks(); updateTranscodeProgressBar(0); // Bind events diff --git a/server.js b/server.js index 0162c67..4859897 100644 --- a/server.js +++ b/server.js @@ -72,36 +72,33 @@ 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)' } + { value: 'libx264', label: 'libx264 (Software / NEON H.264)' }, + { value: 'libx265', label: 'libx265 (Software / NEON H.265)' } ]; +const AVAILABLE_VIDEO_DECODERS = [ + { value: 'auto', label: 'Auto Select Decoder' } +]; + +const SUPPORTED_TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'text', 'webvtt']); + 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 getCachePathParts = (bucket, key) => { + 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`); + return { + progressKey, + safeKeySegments, + tmpInputPath + }; +}; const addWsClient = (progressKey, ws) => { if (!wsSubscriptions.has(progressKey)) { @@ -220,6 +217,73 @@ const shouldRetryWithSoftware = (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 ensureSourceCached = async ({ s3Client, bucket, key, tmpInputPath, onProgress }) => { + if (fs.existsSync(tmpInputPath)) { + const stats = fs.statSync(tmpInputPath); + return { + totalBytes: stats.size, + downloadedBytes: stats.size, + cacheExists: true + }; + } + + const command = new GetObjectCommand({ Bucket: bucket, Key: key }); + const response = await s3Client.send(command); + const s3Stream = response.Body; + const totalBytes = response.ContentLength || 0; + let downloadedBytes = 0; + + if (typeof onProgress === 'function') { + onProgress({ totalBytes, downloadedBytes, cacheExists: false }); + } + + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(tmpInputPath); + s3Stream.on('data', (chunk) => { + downloadedBytes += chunk.length; + if (typeof onProgress === 'function') { + onProgress({ totalBytes, downloadedBytes, cacheExists: false }); + } + }); + s3Stream.on('error', reject); + writeStream.on('error', reject); + writeStream.on('finish', resolve); + s3Stream.pipe(writeStream); + }); + + return { + totalBytes, + downloadedBytes, + cacheExists: false + }; +}; + +const getSubtitleTracks = (metadata) => { + return (metadata?.streams || []) + .filter((stream) => stream.codec_type === 'subtitle') + .map((stream, orderIndex) => { + const codec = (stream.codec_name || '').toLowerCase(); + const language = stream.tags?.language || ''; + const title = stream.tags?.title || ''; + const supported = SUPPORTED_TEXT_SUBTITLE_CODECS.has(codec); + const labelParts = [ + language ? language.toUpperCase() : `Subtitle ${orderIndex + 1}`, + title || null, + codec ? codec : null, + supported ? null : 'Unsupported' + ].filter(Boolean); + + return { + index: stream.index, + codec, + language, + title, + supported, + label: labelParts.join(' / ') + }; + }); +}; + const probeFile = (filePath) => { return new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { @@ -410,6 +474,75 @@ app.get('/api/config', (req, res) => { }); }); +app.get('/api/subtitles', async (req, res) => { + const bucket = req.query.bucket; + const key = req.query.key; + const requestedStreamIndex = typeof req.query.streamIndex === 'string' ? req.query.streamIndex.trim() : ''; + const startSeconds = Math.max(0, parseFloat(req.query.ss) || 0); + + if (!bucket) { + return res.status(400).json({ error: 'Bucket name is required' }); + } + if (!key) { + return res.status(400).json({ error: 'Video key is required' }); + } + + const { tmpInputPath } = getCachePathParts(bucket, key); + const auth = extractS3Credentials(req); + const s3Client = createS3Client(auth); + + try { + await ensureSourceCached({ s3Client, bucket, key, tmpInputPath }); + + const metadata = await probeFile(tmpInputPath); + const tracks = getSubtitleTracks(metadata); + + if (!requestedStreamIndex) { + return res.json({ tracks }); + } + + const numericStreamIndex = parseInt(requestedStreamIndex, 10); + const matchedTrack = tracks.find((track) => track.index === numericStreamIndex); + if (!matchedTrack) { + return res.status(404).json({ error: 'Subtitle track not found' }); + } + if (!matchedTrack.supported) { + return res.status(415).json({ error: 'Subtitle codec is not supported for WebVTT output', codec: matchedTrack.codec }); + } + + res.setHeader('Content-Type', 'text/vtt; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + + const subtitleCommand = ffmpeg() + .input(tmpInputPath) + .outputOptions(['-map', `0:${numericStreamIndex}`, '-reset_timestamps', '1']) + .format('webvtt') + .noAudio() + .noVideo(); + + if (startSeconds > 0) { + if (typeof subtitleCommand.seekInput === 'function') { + subtitleCommand.seekInput(startSeconds); + } else { + subtitleCommand.inputOptions(['-ss', startSeconds.toString()]); + } + } + + subtitleCommand + .on('error', (error) => { + if (!res.headersSent) { + res.status(500).json({ error: 'Failed to extract subtitles', detail: error.message }); + } else { + res.destroy(error); + } + }) + .pipe(res, { end: true }); + } catch (error) { + console.error('Error serving subtitles:', error); + res.status(500).json({ error: 'Failed to load subtitles', detail: error.message }); + } +}); + app.post('/api/clear-download-cache', (req, res) => { try { clearDownloadCache(); @@ -466,11 +599,7 @@ app.get('/api/stream', async (req, res) => { 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('/'); - const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); - const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); - const cacheExists = fs.existsSync(tmpInputPath); + const { progressKey, tmpInputPath } = getCachePathParts(bucket, key); const auth = extractS3Credentials(req); const s3Client = createS3Client(auth); @@ -492,13 +621,9 @@ app.get('/api/stream', async (req, res) => { let totalBytes = 0; let downloadedBytes = 0; + const cacheExists = fs.existsSync(tmpInputPath); if (!cacheExists) { - const command = new GetObjectCommand({ Bucket: bucket, Key: key }); - const response = await s3Client.send(command); - const s3Stream = response.Body; - totalBytes = response.ContentLength || 0; - progressMap[progressKey] = { status: 'downloading', percent: 0, @@ -510,10 +635,14 @@ app.get('/api/stream', async (req, res) => { }; broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); - await new Promise((resolve, reject) => { - const writeStream = fs.createWriteStream(tmpInputPath); - s3Stream.on('data', (chunk) => { - downloadedBytes += chunk.length; + await ensureSourceCached({ + s3Client, + bucket, + key, + tmpInputPath, + onProgress: ({ totalBytes: nextTotalBytes, downloadedBytes: nextDownloadedBytes }) => { + totalBytes = nextTotalBytes; + downloadedBytes = nextDownloadedBytes; const percent = totalBytes ? Math.min(100, Math.round(downloadedBytes / totalBytes * 100)) : 0; const downloadState = { status: 'downloading', @@ -526,11 +655,7 @@ app.get('/api/stream', async (req, res) => { }; 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] = {