From be953b162107036e39c83fb8610a31275fa58ac5 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Thu, 9 Apr 2026 23:31:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E8=A7=86=E9=A2=91=E5=AD=97=E5=B9=95?= =?UTF-8?q?=E5=B5=8C=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/style.css | 44 ++++++++++++++++ public/index.html | 6 +++ public/js/main.js | 65 ++++++++++++++++++++++-- server.js | 118 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 216 insertions(+), 17 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 5bb337f..5dcc294 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -647,6 +647,50 @@ header p { margin-bottom: 0.5rem; } +.now-playing-actions { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +.subtitle-panel { + display: flex; + align-items: center; + gap: 0.5rem; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + padding: 0.5rem 0.8rem; + border-radius: 12px; +} + +.subtitle-panel label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-secondary); + white-space: nowrap; + margin: 0; + text-transform: none; + letter-spacing: normal; +} + +.subtitle-panel select { + background: transparent; + border: none; + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 600; + outline: none; + cursor: pointer; + min-width: 100px; +} + +.subtitle-panel select option { + background: var(--bg-dark); + color: var(--text-primary); +} + .now-playing p { font-size: 1.25rem; font-weight: 600; diff --git a/public/index.html b/public/index.html index de0caa4..fd770b1 100644 --- a/public/index.html +++ b/public/index.html @@ -140,6 +140,12 @@

Now Playing

video.mp4

+ diff --git a/public/js/main.js b/public/js/main.js index 383991c..6cc5792 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -23,6 +23,8 @@ document.addEventListener('DOMContentLoaded', () => { const themeSelector = document.getElementById('theme-selector'); const videoListHeader = document.getElementById('video-list-header'); const logoutBtn = document.getElementById('logout-btn'); + const subtitlePanel = document.getElementById('subtitle-panel'); + const subtitleSelector = document.getElementById('subtitle-selector'); const topBannerTitle = document.getElementById('top-banner-title'); const playBtn = document.getElementById('play-btn'); const topBanner = document.getElementById('top-banner'); @@ -143,6 +145,12 @@ document.addEventListener('DOMContentLoaded', () => { 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)}`; + + const subIndex = subtitleSelector?.value; + if (subIndex && subIndex !== '-1') { + streamUrl += `&subtitleIndex=${encodeURIComponent(subIndex)}`; + } + const sessionId = localStorage.getItem('sessionId'); if (sessionId) { streamUrl += `&sessionId=${encodeURIComponent(sessionId)}`; @@ -265,16 +273,22 @@ document.addEventListener('DOMContentLoaded', () => { }; const subscribeToKey = (key) => { - subscribedKey = key; + let subscriptionKey = key; + const subIndex = subtitleSelector?.value; + if (subIndex && subIndex !== '-1') { + subscriptionKey = `${key}-sub${subIndex}`; + } + + subscribedKey = subscriptionKey; if (wsConnected) { - sendWsMessage({ type: 'subscribe', key }); + sendWsMessage({ type: 'subscribe', key: subscriptionKey }); } }; const handleWsMessage = (event) => { try { const message = JSON.parse(event.data); - if (message.key !== currentVideoKey) return; + if (message.key !== subscribedKey) return; if (message.type === 'duration' && message.duration) { videoDuration = message.duration; @@ -891,6 +905,51 @@ document.addEventListener('DOMContentLoaded', () => { nowPlaying.classList.remove('hidden'); currentVideoTitle.textContent = key.split('/').pop(); + + // Fetch subtitle metadata + fetchVideoMetadata(selectedBucket, key); + }; + + const handleSubtitleChange = () => { + if (!selectedKey) return; + subscribeToKey(selectedKey); + }; + if (subtitleSelector) { + subtitleSelector.addEventListener('change', handleSubtitleChange); + } + + const fetchVideoMetadata = async (bucket, key) => { + if (!subtitlePanel || !subtitleSelector) return; + + subtitlePanel.classList.add('hidden'); + subtitleSelector.innerHTML = ''; + subtitleSelector.disabled = true; + + try { + const res = await fetch(`/api/video-metadata?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`, { headers: s3AuthHeaders }); + if (!res.ok) throw new Error('Failed to fetch metadata'); + const data = await res.json(); + + subtitleSelector.innerHTML = ''; + if (data.subtitleStreams && data.subtitleStreams.length > 0) { + data.subtitleStreams.forEach(sub => { + const option = document.createElement('option'); + option.value = sub.subIndex; + option.textContent = `[${sub.language}] ${sub.title}`; + subtitleSelector.appendChild(option); + }); + subtitlePanel.classList.remove('hidden'); + } else { + subtitleSelector.innerHTML = ''; + subtitlePanel.classList.remove('hidden'); + } + } catch (err) { + console.error('Fetch metadata failed:', err); + subtitleSelector.innerHTML = ''; + subtitlePanel.classList.remove('hidden'); + } finally { + subtitleSelector.disabled = false; + } }; const startTranscode = async () => { diff --git a/server.js b/server.js index 5d3c6a7..8c86b20 100644 --- a/server.js +++ b/server.js @@ -159,10 +159,11 @@ const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[ const makeSafeName = (name) => name.replace(/[^a-zA-Z0-9_\-]/g, '_'); -const getHlsCacheDir = (bucket, key) => { +const getHlsCacheDir = (bucket, key, subtitleIndex = null) => { const safeBucket = makeSafeName(bucket); const safeKey = key.split('/').map(makeSafeName).join('-'); - return path.join(CACHE_DIR, `hls-${safeBucket}-${safeKey}`); + const subSuffix = (subtitleIndex !== null && subtitleIndex !== undefined && subtitleIndex !== '-1') ? `-sub${subtitleIndex}` : ''; + return path.join(CACHE_DIR, `hls-${safeBucket}-${safeKey}${subSuffix}`); }; const getInputCachePath = (bucket, key) => { @@ -649,10 +650,19 @@ app.post('/api/clear-video-transcode-cache', async (req, res) => { if (!bucket || !key) { return res.status(400).json({ error: 'Bucket and key are required' }); } - const hlsDir = getHlsCacheDir(bucket, key); + + const safeBucket = makeSafeName(bucket); + const safeKey = key.split('/').map(makeSafeName).join('-'); + const cachePrefix = `hls-${safeBucket}-${safeKey}`; - if (fs.existsSync(hlsDir)) { - fs.rmSync(hlsDir, { recursive: true, force: true }); + const files = fs.readdirSync(CACHE_DIR); + for (const file of files) { + if (file === cachePrefix || file.startsWith(`${cachePrefix}-sub`)) { + const fullPath = path.join(CACHE_DIR, file); + if (fs.statSync(fullPath).isDirectory()) { + fs.rmSync(fullPath, { recursive: true, force: true }); + } + } } res.json({ message: 'Transcode cache cleared for video' }); } catch (error) { @@ -727,9 +737,44 @@ const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => { return false; }; +app.get('/api/video-metadata', async (req, res) => { + try { + const bucket = req.query.bucket; + const key = req.query.key; + if (!bucket || !key) return res.status(400).json({ error: 'Bucket and key are required' }); + + const auth = await extractS3Credentials(req); + const s3Client = createS3Client(auth); + const tmpInputPath = getInputCachePath(bucket, key); + const progressKey = getProgressKey(key); + + await ensureS3Downloaded(s3Client, bucket, key, tmpInputPath, progressKey, createStreamSessionId()); + const metadata = await probeFile(tmpInputPath); + + const subtitleStreams = (metadata.streams || []) + .filter(s => s.codec_type === 'subtitle') + .map((s, idx) => ({ + index: s.index, + subIndex: idx, // Index among subtitle streams + codec: s.codec_name, + language: s.tags?.language || 'und', + title: s.tags?.title || `Subtitle #${idx + 1} (${s.codec_name})` + })); + + res.json({ + duration: metadata.format?.duration, + subtitleStreams + }); + } catch (error) { + console.error('Error fetching video metadata:', error); + res.status(500).json({ error: 'Failed to fetch video metadata', detail: error.message }); + } +}); + app.get('/api/hls/playlist.m3u8', async (req, res) => { const bucket = req.query.bucket; const key = req.query.key; + const subtitleIndex = req.query.subtitleIndex; // The subIndex (index among subtitle streams) if (!bucket || !key) return res.status(400).send('Bad Request'); const tmpInputPath = getInputCachePath(bucket, key); @@ -762,15 +807,16 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => { } const totalSegments = Math.ceil(duration / HLS_SEGMENT_TIME); - console.log(`[HLS] Generating m3u8: ${totalSegments} segments, duration=${duration}s, key=${key}`); + console.log(`[HLS] Generating m3u8: ${totalSegments} segments, duration=${duration}s, key=${key}, subtitleIndex=${subtitleIndex}`); 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`; + const subtitleParam = subtitleIndex !== undefined && subtitleIndex !== null && subtitleIndex !== '-1' ? `&subtitleIndex=${subtitleIndex}` : ''; 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 += `#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'}${subtitleParam}\n`; } m3u8 += `#EXT-X-ENDLIST\n`; @@ -805,15 +851,18 @@ app.get('/api/hls/segment.ts', async (req, res) => { const seg = parseInt(req.query.seg || '0'); const requestedEncoder = req.query.encoder || 'h264_rkmpp'; const requestedDecoder = req.query.decoder || 'auto'; + const subtitleIndex = req.query.subtitleIndex; if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request'); - console.log(`[HLS] Segment request: seg=${seg}, key=${key}, encoder=${requestedEncoder}`); + console.log(`[HLS] Segment request: seg=${seg}, key=${key}, encoder=${requestedEncoder}, sub=${subtitleIndex}`); const tmpInputPath = getInputCachePath(bucket, key); - const progressKey = getProgressKey(key); - const hlsDir = getHlsCacheDir(bucket, key); + const baseProgressKey = getProgressKey(key); + const subtitleSuffix = (subtitleIndex !== null && subtitleIndex !== undefined && subtitleIndex !== '-1') ? `-sub${subtitleIndex}` : ''; + const progressKey = `${baseProgressKey}${subtitleSuffix}`; + const hlsDir = getHlsCacheDir(bucket, key, subtitleIndex); if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true }); const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`); @@ -859,7 +908,8 @@ app.get('/api/hls/segment.ts', async (req, res) => { const encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp'; const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto'; - console.log(`[HLS] FFmpeg config: encoder=${encoderName}, decoder=${decoderName}, startTime=${startTime}s`); + const subtitleIndex = req.query.subtitleIndex; // The subIndex (index among subtitle streams) + console.log(`[HLS] FFmpeg config: encoder=${encoderName}, decoder=${decoderName}, startTime=${startTime}s, subtitleIndex=${subtitleIndex}`); const m3u8Path = path.join(hlsDir, `temp.m3u8`); if (fs.existsSync(m3u8Path)) fs.unlinkSync(m3u8Path); @@ -869,9 +919,49 @@ app.get('/api/hls/segment.ts', async (req, res) => { ffmpegCommand.videoCodec(encoderName).audioCodec('aac'); + const videoFilters = []; if (isVaapiCodec(encoderName)) { - ffmpegCommand.inputOptions(['-vaapi_device', '/dev/dri/renderD128']).videoFilters('format=nv12,hwupload'); + ffmpegCommand.inputOptions(['-vaapi_device', '/dev/dri/renderD128']); + videoFilters.push('format=nv12', 'hwupload'); } + + // Add subtitle filter if requested + if (subtitleIndex !== undefined && subtitleIndex !== null && subtitleIndex !== '-1') { + const subIdx = parseInt(subtitleIndex); + const subtitleStream = (sourceMetadata?.streams || []) + .filter(s => s.codec_type === 'subtitle')[subIdx]; + + if (subtitleStream) { + console.log(`[HLS] Applying subtitle filter for stream ${subtitleStream.index} (codec: ${subtitleStream.codec_name})`); + // For embedded subtitles, use the subIndex (index among subtitle streams) + // Escape path for Windows + const escapedPath = tmpInputPath.replace(/\\/g, '/').replace(/:/g, '\\:'); + // Use 'ffmpeg' subtitles filter for text-based, or overlay for image-based + const isImageSub = ['pgs', 'dvdsub', 'hdmv_pgs_subtitle', 'dvd_subtitle'].includes(subtitleStream.codec_name); + + if (isImageSub) { + // Image-based subs need complex filter for overlay + // This is more complex with fluent-ffmpeg's high-level API + // We might need to use complexFilter + ffmpegCommand.complexFilter([ + { + filter: 'overlay', + options: { x: 0, y: 0 }, + inputs: ['0:v', `0:s:${subIdx}`], + outputs: 'outv' + } + ], 'outv'); + } else { + // Text-based subs + videoFilters.push(`subtitles='${escapedPath}':si=${subIdx}`); + } + } + } + + if (videoFilters.length > 0) { + ffmpegCommand.videoFilters(videoFilters); + } + const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName) ? getRkmppDecoderName(sourceMetadata) : decoderName; if (resolvedDecoderName && resolvedDecoderName !== 'auto') ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]); @@ -916,8 +1006,8 @@ app.get('/api/hls/segment.ts', async (req, res) => { details: `处理进度 ${percent}%`, mp4Url: null }; - progressMap[progressKey] = progressState; - broadcastWs(progressKey, { type: 'progress', key, progress: progressState }); + const broadcastKey = subtitleIndex && subtitleIndex !== '-1' ? `${key}-sub${subtitleIndex}` : key; + broadcastWs(progressKey, { type: 'progress', key: broadcastKey, progress: progressState }); console.log(`[FFmpeg] ${progressKey} | ${progress.timemark} | ${sanitizeNumber(progress.currentFps) ?? '-'}fps | ${sanitizeNumber(progress.currentKbps) ?? '-'}kbps | ${percent}%`); });