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}`);