From b3f5053482744c853b1356b4f2b9b03611d94903 Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Sat, 4 Apr 2026 01:02:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=95=E5=85=A5m3u8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/index.html | 1 + public/js/main.js | 171 +++++++++++++++++----------------------------- server.js | 156 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 109 deletions(-) diff --git a/public/index.html b/public/index.html index 32a5ac6..67235fc 100644 --- a/public/index.html +++ b/public/index.html @@ -126,6 +126,7 @@ + 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;