diff --git a/public/index.html b/public/index.html index f21e7f7..9804b2c 100644 --- a/public/index.html +++ b/public/index.html @@ -45,7 +45,6 @@
-
diff --git a/public/js/main.js b/public/js/main.js index 849abd2..dcc6a47 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -3,7 +3,6 @@ document.addEventListener('DOMContentLoaded', () => { const loadingSpinner = document.getElementById('loading-spinner'); const refreshBtn = document.getElementById('refresh-btn'); const clearDownloadCacheBtn = document.getElementById('clear-download-cache-btn'); - const clearTranscodeCacheBtn = document.getElementById('clear-transcode-cache-btn'); const bucketSelect = document.getElementById('bucket-select'); const loginScreen = document.getElementById('login-screen'); const appContainer = document.getElementById('app-container'); @@ -224,47 +223,6 @@ document.addEventListener('DOMContentLoaded', () => { } }; - const clearTranscodeCache = async () => { - if (!clearTranscodeCacheBtn) return; - clearTranscodeCacheBtn.disabled = true; - clearTranscodeCacheBtn.textContent = '清空中...'; - - stopPolling(); - selectedKey = null; - currentVideoKey = null; - subscribedKey = null; - if (transcodeBtn) { - transcodeBtn.disabled = true; - transcodeBtn.classList.add('hidden'); - } - if (playBtn) { - playBtn.classList.add('hidden'); - } - if (playerOverlay) { - playerOverlay.classList.remove('hidden'); - } - if (nowPlaying) { - nowPlaying.classList.add('hidden'); - } - resetProgress(); - - try { - const res = await fetch('/api/clear-transcode-cache', { method: 'POST' }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || '清空转码缓存失败'); - } - await fetchVideos(selectedBucket); - alert('转码缓存已清空'); - } catch (err) { - console.error('Clear transcode cache failed:', err); - alert(`清空转码缓存失败: ${err.message}`); - } finally { - clearTranscodeCacheBtn.disabled = false; - clearTranscodeCacheBtn.textContent = '清空转码缓存'; - } - }; - if (transcodeBtn) { transcodeBtn.addEventListener('click', () => { startTranscode(); @@ -440,24 +398,24 @@ document.addEventListener('DOMContentLoaded', () => { } stopPolling(); transcodingOverlay.classList.remove('hidden'); - setProgress({ status: 'Starting download...', percent: 0, details: 'Starting download...' }); + setProgress({ status: 'Starting stream...', percent: 0, details: 'Starting stream...' }); try { const codec = codecSelect?.value || 'h264'; const encoder = encoderSelect?.value || 'software'; if (!selectedBucket) throw new Error('No bucket selected'); - const res = await fetch('/api/transcode', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...s3AuthHeaders }, - body: JSON.stringify({ bucket: selectedBucket, key: selectedKey, codec, encoder }) - }); - const data = await res.json(); - - if (data.error) throw new Error(data.error); - - if (!wsConnected) { - pollForMp4Ready(selectedKey, data.mp4Url); - } + const streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&codec=${encodeURIComponent(codec)}&encoder=${encodeURIComponent(encoder)}`; + videoPlayer.src = streamUrl; + videoPlayer.load(); + videoPlayer.addEventListener('loadedmetadata', () => { + transcodingOverlay.classList.add('hidden'); + videoPlayer.classList.remove('hidden'); + if (playBtn) { + playBtn.disabled = false; + playBtn.textContent = 'Play'; + playBtn.classList.remove('hidden'); + } + }, { once: true }); } catch (err) { console.error(err); transcodingOverlay.innerHTML = `

Transcode Failed: ${err.message}

`; @@ -496,36 +454,6 @@ document.addEventListener('DOMContentLoaded', () => { } }; - // Poll the backend to check if the generated MP4 file is accessible - const pollForMp4Ready = (key, mp4Url) => { - let attempts = 0; - const maxAttempts = 120; // 60 seconds max wait for first segment - const pollIntervalMs = 500; - - currentPollInterval = setInterval(async () => { - attempts++; - try { - const res = await fetch(`/api/status?key=${encodeURIComponent(key)}`); - const data = await res.json(); - - if (data.progress) { - setProgress(data.progress); - } - - if (data.ready) { - stopPolling(); - setProgress({ status: 'Ready to play', percent: 100, details: 'Ready to play' }); - playMp4Stream(data.mp4Url); - } else if (attempts >= maxAttempts) { - stopPolling(); - transcodingOverlay.innerHTML = `

Timeout waiting for MP4 file.

`; - } - } catch (err) { - console.error("Poll Error:", err); - } - }, pollIntervalMs); - }; - const stopPolling = () => { if (currentPollInterval) { clearInterval(currentPollInterval); @@ -559,9 +487,6 @@ document.addEventListener('DOMContentLoaded', () => { if (clearDownloadCacheBtn) { clearDownloadCacheBtn.addEventListener('click', clearDownloadCache); } - if (clearTranscodeCacheBtn) { - clearTranscodeCacheBtn.addEventListener('click', clearTranscodeCache); - } if (stopTranscodeBtn) { stopTranscodeBtn.addEventListener('click', stopTranscode); } diff --git a/server.js b/server.js index 0d2122a..1a876f5 100644 --- a/server.js +++ b/server.js @@ -149,13 +149,6 @@ wss.on('connection', (ws) => { ws.on('close', () => removeWsClient(ws)); }); -const mp4BaseDir = path.join(__dirname, 'public', 'mp4'); - -const ensureDirectoryExists = (dirPath) => { - if (!fs.existsSync(dirPath)) { - fs.mkdirSync(dirPath, { recursive: true }); - } -}; const clearDownloadCache = () => { const tmpDir = os.tmpdir(); @@ -174,33 +167,6 @@ const clearDownloadCache = () => { } }; -const clearMp4Cache = () => { - if (!fs.existsSync(mp4BaseDir)) return; - try { - fs.rmSync(mp4BaseDir, { recursive: true, force: true }); - } catch (err) { - if (typeof fs.rmdirSync === 'function') { - fs.rmdirSync(mp4BaseDir, { recursive: true }); - } else { - throw err; - } - } -}; - -const clearTranscodeCache = () => { - for (const command of transcodeProcesses.values()) { - try { - if (typeof command.kill === 'function') { - command.kill('SIGKILL'); - } - } catch (_) {} - } - transcodeProcesses.clear(); - clearMp4Cache(); - Object.keys(progressMap).forEach((key) => delete progressMap[key]); -}; - -ensureDirectoryExists(mp4BaseDir); // Endpoint to list available buckets app.get('/api/buckets', async (req, res) => { @@ -266,16 +232,6 @@ app.get('/api/config', (req, res) => { res.json({ title }); }); -app.post('/api/reset-cache', (req, res) => { - try { - clearTranscodeCache(); - res.json({ message: 'Cache reset' }); - } catch (error) { - console.error('Error resetting cache:', error); - res.status(500).json({ error: 'Failed to reset cache', detail: error.message }); - } -}); - app.post('/api/clear-download-cache', (req, res) => { try { clearDownloadCache(); @@ -286,16 +242,6 @@ app.post('/api/clear-download-cache', (req, res) => { } }); -app.post('/api/clear-transcode-cache', (req, res) => { - try { - clearTranscodeCache(); - res.json({ message: 'Transcode cache cleared' }); - } catch (error) { - console.error('Error clearing transcode cache:', error); - res.status(500).json({ error: 'Failed to clear transcode cache', detail: error.message }); - } -}); - app.post('/api/stop-transcode', (req, res) => { try { const { key } = req.body; @@ -322,7 +268,7 @@ app.post('/api/stop-transcode', (req, res) => { status: 'cancelled', percent: 0, details: 'Transcode stopped by user', - mp4Url: `/mp4/${progressKey}/video.mp4` + mp4Url: null }; broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); res.json({ message: 'Transcode stopped' }); @@ -332,209 +278,6 @@ app.post('/api/stop-transcode', (req, res) => { } }); -// Endpoint to transcode S3 video streaming to MP4 -app.post('/api/transcode', async (req, res) => { - const { bucket, key, codec, encoder } = req.body; - if (!bucket) { - return res.status(400).json({ error: 'Bucket name is required' }); - } - if (!key) { - return res.status(400).json({ error: 'Video key is required' }); - } - - if (!key) { - return res.status(400).json({ error: 'Video key is required' }); - } - - const safeCodec = codec === 'h265' ? 'h265' : 'h264'; - const safeEncoder = ['nvidia', 'intel', 'vaapi', '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' } - }; - const videoCodec = codecMap[safeEncoder][safeCodec]; - - try { - const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); - const progressKey = safeKeySegments.join('/'); - const outputDir = path.join(__dirname, 'public', 'mp4', ...safeKeySegments); - const mp4Path = path.join(outputDir, 'video.mp4'); - const mp4Url = `/mp4/${progressKey}/video.mp4`; - - progressMap[progressKey] = { status: 'pending', percent: 0, details: 'Waiting for ffmpeg to start', mp4Url }; - let currentFfmpegCommand = null; - - // If it already exists, just return the URL - if (fs.existsSync(mp4Path)) { - return res.json({ message: 'Already transcoded', mp4Url }); - } - - // Create output directory if it doesn't exist - fs.mkdirSync(outputDir, { recursive: true }); - - // Get S3 stream - const auth = extractS3Credentials(req); - const s3Client = createS3Client(auth); - 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; - const tmpInputPath = path.join(os.tmpdir(), `s3-input-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`); - - const broadcastDownloadProgress = () => { - const percent = totalBytes ? Math.min(100, Math.round(downloadedBytes / totalBytes * 100)) : 0; - const downloadState = { - status: 'downloading', - percent, - downloadedBytes, - totalBytes, - details: totalBytes ? `Downloading ${percent}%` : 'Downloading...', - mp4Url - }; - progressMap[progressKey] = downloadState; - broadcastWs(progressKey, { type: 'progress', key, progress: downloadState }); - }; - - await new Promise((resolve, reject) => { - const writeStream = fs.createWriteStream(tmpInputPath); - s3Stream.on('data', (chunk) => { - downloadedBytes += chunk.length; - broadcastDownloadProgress(); - }); - s3Stream.on('error', (err) => { - reject(err); - }); - writeStream.on('error', reject); - writeStream.on('finish', resolve); - s3Stream.pipe(writeStream); - }); - - broadcastDownloadProgress(); - progressMap[progressKey] = { - status: 'downloaded', - percent: 100, - downloadedBytes, - totalBytes, - details: 'Download complete, starting transcode...', - mp4Url - }; - broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); - - // Triggers fluent-ffmpeg to transcode to MP4 - console.log(`Starting transcoding for ${key} with codec ${videoCodec}`); - - const cleanupTmpInput = () => { - fs.unlink(tmpInputPath, () => {}); - }; - - let attemptedSoftwareFallback = false; - const startFfmpeg = (encoderName) => { - console.log(`Starting ffmpeg with encoder ${encoderName} for ${key}`); - const command = ffmpeg(tmpInputPath) - .videoCodec(encoderName) - .audioCodec('aac') - .outputOptions(createFfmpegOptions(encoderName)); - transcodeProcesses.set(progressKey, command); - currentFfmpegCommand = command; - if (/_vaapi$/.test(encoderName)) { - command - .inputOptions(['-vaapi_device', '/dev/dri/renderD128']) - .videoFilters('format=nv12,hwupload'); - } - command - .format('mp4') - .output(mp4Path) - .on('progress', (progress) => { - const progressState = { - status: 'transcoding', - percent: Math.min(Math.max(Math.round(progress.percent || 0), 0), 100), - frame: progress.frames || null, - fps: progress.currentFps || null, - bitrate: progress.currentKbps || null, - timemark: progress.timemark || null, - details: `Transcoding... ${Math.min(Math.max(Math.round(progress.percent || 0), 0), 100)}%`, - mp4Url - }; - progressMap[progressKey] = progressState; - broadcastWs(progressKey, { type: 'progress', key, progress: progressState }); - }) - .on('stderr', (stderrLine) => { - console.log(`ffmpeg stderr: ${stderrLine}`); - }) - .on('end', () => { - cleanupTmpInput(); - console.log(`Finished transcoding ${key} to MP4`); - let progressState; - try { - const stats = fs.statSync(mp4Path); - if (!stats.isFile() || stats.size === 0) { - throw new Error('Output MP4 is empty or missing'); - } - progressState = { status: 'finished', percent: 100, details: 'Transcoding complete', mp4Url }; - } catch (verifyError) { - console.error(`Output verification failed for ${mp4Path}:`, verifyError); - progressState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: `Output verification failed: ${verifyError.message}`, mp4Url }; - } - transcodeProcesses.delete(progressKey); - progressMap[progressKey] = progressState; - broadcastWs(progressKey, { type: 'progress', key, progress: progressState }); - if (progressState.status === 'finished') { - broadcastWs(progressKey, { type: 'ready', key, mp4Url }); - } - }) - .on('error', (err) => { - const errMessage = err?.message || ''; - const isHardwareFailure = !attemptedSoftwareFallback && encoderName !== codecMap.software[safeCodec] && shouldRetryWithSoftware(errMessage); - if (isHardwareFailure) { - transcodeProcesses.delete(progressKey); - attemptedSoftwareFallback = true; - console.warn(`Hardware encoder failed for ${key}; retrying with software encoder`, errMessage); - try { - if (fs.existsSync(mp4Path)) { - fs.unlinkSync(mp4Path); - } - } catch (_) {} - const softwareEncoder = codecMap.software[safeCodec]; - progressMap[progressKey] = { - status: 'fallback', - percent: 0, - details: 'Hardware encoder unavailable, retrying with software encoder...', - mp4Url - }; - broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); - startFfmpeg(softwareEncoder); - return; - } - - cleanupTmpInput(); - transcodeProcesses.delete(progressKey); - console.error(`Error transcoding ${key}:`, err); - const failedState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: err.message || 'Transcoding failed', mp4Url }; - progressMap[progressKey] = failedState; - broadcastWs(progressKey, { type: 'progress', key, progress: failedState }); - }) - .run(); - }; - - startFfmpeg(videoCodec); - - // Return immediately so the client can start polling or waiting - res.json({ message: 'Transcoding started', mp4Url }); - - } catch (error) { - console.error('Error in transcode:', error); - res.status(500).json({ error: 'Failed to initiate transcoding', detail: error.message }); - } -}); - app.get('/api/stream', async (req, res) => { const bucket = req.query.bucket; const key = req.query.key; @@ -723,24 +466,6 @@ app.get('/api/stream', async (req, res) => { } }); -// Status check for MP4 availability -app.get('/api/status', (req, res) => { - const { key } = req.query; - if (!key) return res.status(400).json({ error: 'Key is required' }); - - const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); - const progressKey = safeKeySegments.join('/'); - const mp4Path = path.join(__dirname, 'public', 'mp4', ...safeKeySegments, 'video.mp4'); - const progress = progressMap[progressKey] || null; - - const outputReady = fs.existsSync(mp4Path) && (!progress || progress.status !== 'failed'); - if (outputReady) { - res.json({ ready: true, mp4Url: `/mp4/${safeKeySegments.join('/')}/video.mp4`, progress }); - } else { - res.json({ ready: false, progress }); - } -}); - server.listen(PORT, HOST, () => { console.log(`Server running on http://${HOST}:${PORT}`); });