添加预制切片

This commit is contained in:
2026-04-10 00:27:25 +08:00
parent fce0e3d581
commit e0adb66713
3 changed files with 220 additions and 129 deletions

View File

@@ -148,6 +148,7 @@
</div> </div>
<button id="transcode-btn" class="play-btn hidden">开始播放</button> <button id="transcode-btn" class="play-btn hidden">开始播放</button>
<button id="stop-transcode-btn" class="play-btn stop-btn hidden">停止播放</button> <button id="stop-transcode-btn" class="play-btn stop-btn hidden">停止播放</button>
<button id="pre-slice-btn" class="play-btn stop-btn hidden">预切片 (HLS)</button>
<button id="clear-playing-download-cache-btn" class="play-btn stop-btn hidden">清空下载缓存</button> <button id="clear-playing-download-cache-btn" class="play-btn stop-btn hidden">清空下载缓存</button>
<button id="clear-playing-transcode-cache-btn" class="play-btn stop-btn hidden">清空转码缓存</button> <button id="clear-playing-transcode-cache-btn" class="play-btn stop-btn hidden">清空转码缓存</button>
</div> </div>

View File

@@ -18,6 +18,7 @@ document.addEventListener('DOMContentLoaded', () => {
const currentVideoTitle = document.getElementById('current-video-title'); const currentVideoTitle = document.getElementById('current-video-title');
const transcodeBtn = document.getElementById('transcode-btn'); const transcodeBtn = document.getElementById('transcode-btn');
const stopTranscodeBtn = document.getElementById('stop-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 clearPlayingDownloadBtn = document.getElementById('clear-playing-download-cache-btn');
const clearPlayingTranscodeBtn = document.getElementById('clear-playing-transcode-cache-btn'); const clearPlayingTranscodeBtn = document.getElementById('clear-playing-transcode-cache-btn');
const themeSelector = document.getElementById('theme-selector'); const themeSelector = document.getElementById('theme-selector');
@@ -885,6 +886,10 @@ document.addEventListener('DOMContentLoaded', () => {
stopTranscodeBtn.textContent = '停止播放'; stopTranscodeBtn.textContent = '停止播放';
} }
if (preSliceBtn) {
preSliceBtn.classList.remove('hidden');
preSliceBtn.disabled = false;
}
if (clearPlayingDownloadBtn) { if (clearPlayingDownloadBtn) {
if (hasDownloadCache) clearPlayingDownloadBtn.classList.remove('hidden'); if (hasDownloadCache) clearPlayingDownloadBtn.classList.remove('hidden');
else clearPlayingDownloadBtn.classList.add('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 = () => { const stopPolling = () => {
if (currentPollInterval) { if (currentPollInterval) {
clearInterval(currentPollInterval); clearInterval(currentPollInterval);

305
server.js
View File

@@ -864,7 +864,8 @@ const hlsProcesses = new Map();
setInterval(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [key, processInfo] of hlsProcesses.entries()) { 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 { try {
if (processInfo.command) { if (processInfo.command) {
processInfo.command.kill('SIGKILL'); processInfo.command.kill('SIGKILL');
@@ -878,6 +879,178 @@ setInterval(() => {
} }
}, 10000); }, 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) => { app.get('/api/hls/segment.ts', async (req, res) => {
const bucket = req.query.bucket; const bucket = req.query.bucket;
const key = req.query.key; const key = req.query.key;
@@ -937,138 +1110,12 @@ app.get('/api/hls/segment.ts', async (req, res) => {
if (needsNewProcess) { if (needsNewProcess) {
console.log(`[HLS] Starting new FFmpeg process for seg=${seg}, key=${key}`); console.log(`[HLS] Starting new FFmpeg process for seg=${seg}, key=${key}`);
if (currentProcess && currentProcess.command) { currentProcess = await startHlsTranscode(bucket, key, seg, requestedEncoder, requestedDecoder, subtitleIndex);
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);
} else { } else {
console.log(`[HLS] Reusing existing FFmpeg process for seg=${seg}, currentSeg=${currentProcess?.currentSeg}`); console.log(`[HLS] Reusing existing FFmpeg process for seg=${seg}, currentSeg=${currentProcess?.currentSeg}`);
} }
const ready = await waitForSegment(hlsDir, seg); const ready = await waitForSegment(hlsDir, seg);
if (!ready) { if (!ready) {
console.error(`[HLS] Segment generation timeout: seg=${seg}, key=${key}`); console.error(`[HLS] Segment generation timeout: seg=${seg}, key=${key}`);