diff --git a/public/index.html b/public/index.html index fd770b1..afce68b 100644 --- a/public/index.html +++ b/public/index.html @@ -148,6 +148,7 @@ + diff --git a/public/js/main.js b/public/js/main.js index 216ff3e..a3ed062 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -18,6 +18,7 @@ document.addEventListener('DOMContentLoaded', () => { const currentVideoTitle = document.getElementById('current-video-title'); const transcodeBtn = document.getElementById('transcode-btn'); const stopTranscodeBtn = document.getElementById('stop-transcode-btn'); + const preSliceBtn = document.getElementById('pre-slice-btn'); const clearPlayingDownloadBtn = document.getElementById('clear-playing-download-cache-btn'); const clearPlayingTranscodeBtn = document.getElementById('clear-playing-transcode-cache-btn'); const themeSelector = document.getElementById('theme-selector'); @@ -885,6 +886,10 @@ document.addEventListener('DOMContentLoaded', () => { stopTranscodeBtn.textContent = '停止播放'; } + if (preSliceBtn) { + preSliceBtn.classList.remove('hidden'); + preSliceBtn.disabled = false; + } if (clearPlayingDownloadBtn) { if (hasDownloadCache) clearPlayingDownloadBtn.classList.remove('hidden'); else clearPlayingDownloadBtn.classList.add('hidden'); @@ -1098,6 +1103,44 @@ document.addEventListener('DOMContentLoaded', () => { } }; + const preSliceVideo = async () => { + if (!selectedKey) return; + preSliceBtn.disabled = true; + preSliceBtn.textContent = '预切片中...'; + + try { + const sessionId = localStorage.getItem('sessionId'); + const body = { + bucket: selectedBucket, + key: selectedKey, + encoder: encoderSelect?.value || 'h264_rkmpp', + decoder: 'auto', + subtitleIndex: subtitleSelector?.value || '-1', + sessionId + }; + + const res = await fetch('/api/pre-slice', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Pre-slice failed'); + + alert('已在后台开始预切片,您可以在进度条中查看进度。'); + } catch (err) { + console.error('Pre-slice failed:', err); + alert(`预切片失败: ${err.message}`); + preSliceBtn.disabled = false; + preSliceBtn.textContent = '预切片 (HLS)'; + } + }; + + if (preSliceBtn) { + preSliceBtn.addEventListener('click', preSliceVideo); + } + const stopPolling = () => { if (currentPollInterval) { clearInterval(currentPollInterval); diff --git a/server.js b/server.js index 99b5247..93621bc 100644 --- a/server.js +++ b/server.js @@ -864,7 +864,8 @@ const hlsProcesses = new Map(); setInterval(() => { const now = Date.now(); for (const [key, processInfo] of hlsProcesses.entries()) { - if (processInfo.lastActive && now - processInfo.lastActive > 30000) { + // Only kill if NOT persistent and inactive for more than 30s + if (!processInfo.persistent && processInfo.lastActive && now - processInfo.lastActive > 30000) { try { if (processInfo.command) { processInfo.command.kill('SIGKILL'); @@ -878,6 +879,178 @@ setInterval(() => { } }, 10000); +const startHlsTranscode = async (bucket, key, seg, requestedEncoder, requestedDecoder, subtitleIndex, isPersistent = false) => { + const baseProgressKey = getProgressKey(key); + const subtitleSuffix = (subtitleIndex !== null && subtitleIndex !== undefined && subtitleIndex !== '-1') ? `-sub${subtitleIndex}` : ''; + const progressKey = `${baseProgressKey}${subtitleSuffix}`; + const hlsDir = getHlsCacheDir(bucket, key, subtitleIndex); + const tmpInputPath = getInputCachePath(bucket, key); + + if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true }); + + let currentProcess = hlsProcesses.get(progressKey); + if (currentProcess && currentProcess.command) { + console.log(`[HLS] Killing previous FFmpeg process for ${progressKey}`); + 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) { + console.error(`[HLS] Probe failed for segment transcode: ${e.message}`); + } + + 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, subtitleIndex=${subtitleIndex}, persistent=${isPersistent}`); + + 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'); + + const videoFilters = []; + if (isVaapiCodec(encoderName)) { + 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})`); + const escapedPath = tmpInputPath.replace(/\\/g, '/').replace(/:/g, '\\:'); + const isImageSub = ['pgs', 'dvdsub', 'hdmv_pgs_subtitle', 'dvd_subtitle'].includes(subtitleStream.codec_name); + + if (isImageSub) { + ffmpegCommand.complexFilter([ + { + filter: 'overlay', + options: { x: 0, y: 0 }, + inputs: ['0:v', `0:s:${subIdx}`], + outputs: 'outv' + } + ], 'outv'); + } else { + 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]); + + 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(), + '-copyts', + '-avoid_negative_ts', 'disabled', + '-muxdelay', '0', + '-muxpreload', '0' + ]); + + ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path); + ffmpegCommand.on('error', (err) => { + console.error(`[HLS] FFmpeg Error for ${progressKey}:`, 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: sanitizeNumber(progress.frames), + fps: sanitizeNumber(progress.currentFps), + bitrate: sanitizeNumber(progress.currentKbps), + timemark: progress.timemark || null, + absoluteSeconds, + duration: totalDuration || null, + startSeconds: startTime, + details: isPersistent ? `后台预切片中 ${percent}%` : `处理进度 ${percent}%`, + mp4Url: null + }; + const broadcastKey = subtitleIndex && subtitleIndex !== '-1' ? `${key}-sub${subtitleIndex}` : key; + broadcastWs(progressKey, { type: 'progress', key: broadcastKey, progress: progressState }); + + if (!isPersistent) { + console.log(`[FFmpeg] ${progressKey} | ${progress.timemark} | ${sanitizeNumber(progress.currentFps) ?? '-'}fps | ${sanitizeNumber(progress.currentKbps) ?? '-'}kbps | ${percent}%`); + } + }); + + ffmpegCommand.on('end', () => { + console.log(`[FFmpeg] ${progressKey} HLS transcode completed.`); + const broadcastKey = subtitleIndex && subtitleIndex !== '-1' ? `${key}-sub${subtitleIndex}` : key; + broadcastWs(progressKey, { + type: 'progress', + key: broadcastKey, + progress: { + status: 'finished', + percent: 100, + details: isPersistent ? '预切片完成' : '处理完成' + } + }); + hlsProcesses.delete(progressKey); + }); + + ffmpegCommand.run(); + console.log(`[HLS] FFmpeg process started for ${progressKey} (persistent=${isPersistent})`); + const newProcessInfo = { command: ffmpegCommand, currentSeg: seg, lastActive: Date.now(), persistent: isPersistent }; + hlsProcesses.set(progressKey, newProcessInfo); + return newProcessInfo; +}; + +app.post('/api/pre-slice', async (req, res) => { + try { + const { bucket, key, encoder, decoder, subtitleIndex } = req.body; + if (!bucket || !key) return res.status(400).json({ error: 'Bucket and key are required' }); + + const tmpInputPath = getInputCachePath(bucket, key); + const auth = await extractS3Credentials(req); + const s3Client = createS3Client(auth); + const progressKey = getProgressKey(key); + + console.log(`[Pre-slice] Starting pre-slice for ${key}`); + + // Ensure downloaded + await ensureS3Downloaded(s3Client, bucket, key, tmpInputPath, progressKey, createStreamSessionId()); + + // Trigger HLS transcode as persistent (won't be killed by watchdog) + await startHlsTranscode(bucket, key, 0, encoder || 'h264_rkmpp', decoder || 'auto', subtitleIndex || '-1', true); + + res.json({ message: 'Pre-slicing started in background' }); + } catch (error) { + console.error('Error starting pre-slice:', error); + res.status(500).json({ error: 'Failed to start pre-slice', detail: error.message }); + } +}); + app.get('/api/hls/segment.ts', async (req, res) => { const bucket = req.query.bucket; const key = req.query.key; @@ -937,138 +1110,12 @@ app.get('/api/hls/segment.ts', async (req, res) => { if (needsNewProcess) { console.log(`[HLS] Starting new FFmpeg process for seg=${seg}, key=${key}`); - if (currentProcess && currentProcess.command) { - console.log(`[HLS] Killing previous FFmpeg process for ${progressKey}`); - 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) { - console.error(`[HLS] Probe failed for segment transcode: ${e.message}`); - } - - const encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp'; - const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto'; - 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); - - const ffmpegCommand = ffmpeg().input(tmpInputPath); - if (startTime > 0) ffmpegCommand.seekInput(startTime); - - ffmpegCommand.videoCodec(encoderName).audioCodec('aac'); - - const videoFilters = []; - if (isVaapiCodec(encoderName)) { - 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]); - - 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(), - '-copyts', - '-avoid_negative_ts', 'disabled', - '-muxdelay', '0', - '-muxpreload', '0' - ]); - - ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path); - ffmpegCommand.on('error', (err) => { - console.error(`[HLS] FFmpeg Error for ${progressKey}:`, 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: sanitizeNumber(progress.frames), - fps: sanitizeNumber(progress.currentFps), - bitrate: sanitizeNumber(progress.currentKbps), - timemark: progress.timemark || null, - absoluteSeconds, - duration: totalDuration || null, - startSeconds: startTime, - details: `处理进度 ${percent}%`, - mp4Url: null - }; - 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}%`); - }); - - ffmpegCommand.on('end', () => { - console.log(`[FFmpeg] ${progressKey} HLS transcode completed.`); - }); - - ffmpegCommand.run(); - console.log(`[HLS] FFmpeg process started for ${progressKey}`); - currentProcess = { command: ffmpegCommand, currentSeg: seg, lastActive: Date.now() }; - hlsProcesses.set(progressKey, currentProcess); + currentProcess = await startHlsTranscode(bucket, key, seg, requestedEncoder, requestedDecoder, subtitleIndex); } else { console.log(`[HLS] Reusing existing FFmpeg process for seg=${seg}, currentSeg=${currentProcess?.currentSeg}`); } + const ready = await waitForSegment(hlsDir, seg); if (!ready) { console.error(`[HLS] Segment generation timeout: seg=${seg}, key=${key}`);