将视频字幕嵌入

This commit is contained in:
CN-JS-HuiBai
2026-04-09 23:31:32 +08:00
parent 116db4cb0f
commit be953b1621
4 changed files with 216 additions and 17 deletions

118
server.js
View File

@@ -159,10 +159,11 @@ const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[
const makeSafeName = (name) => name.replace(/[^a-zA-Z0-9_\-]/g, '_');
const getHlsCacheDir = (bucket, key) => {
const getHlsCacheDir = (bucket, key, subtitleIndex = null) => {
const safeBucket = makeSafeName(bucket);
const safeKey = key.split('/').map(makeSafeName).join('-');
return path.join(CACHE_DIR, `hls-${safeBucket}-${safeKey}`);
const subSuffix = (subtitleIndex !== null && subtitleIndex !== undefined && subtitleIndex !== '-1') ? `-sub${subtitleIndex}` : '';
return path.join(CACHE_DIR, `hls-${safeBucket}-${safeKey}${subSuffix}`);
};
const getInputCachePath = (bucket, key) => {
@@ -649,10 +650,19 @@ app.post('/api/clear-video-transcode-cache', async (req, res) => {
if (!bucket || !key) {
return res.status(400).json({ error: 'Bucket and key are required' });
}
const hlsDir = getHlsCacheDir(bucket, key);
const safeBucket = makeSafeName(bucket);
const safeKey = key.split('/').map(makeSafeName).join('-');
const cachePrefix = `hls-${safeBucket}-${safeKey}`;
if (fs.existsSync(hlsDir)) {
fs.rmSync(hlsDir, { recursive: true, force: true });
const files = fs.readdirSync(CACHE_DIR);
for (const file of files) {
if (file === cachePrefix || file.startsWith(`${cachePrefix}-sub`)) {
const fullPath = path.join(CACHE_DIR, file);
if (fs.statSync(fullPath).isDirectory()) {
fs.rmSync(fullPath, { recursive: true, force: true });
}
}
}
res.json({ message: 'Transcode cache cleared for video' });
} catch (error) {
@@ -727,9 +737,44 @@ const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => {
return false;
};
app.get('/api/video-metadata', async (req, res) => {
try {
const bucket = req.query.bucket;
const key = req.query.key;
if (!bucket || !key) return res.status(400).json({ error: 'Bucket and key are required' });
const auth = await extractS3Credentials(req);
const s3Client = createS3Client(auth);
const tmpInputPath = getInputCachePath(bucket, key);
const progressKey = getProgressKey(key);
await ensureS3Downloaded(s3Client, bucket, key, tmpInputPath, progressKey, createStreamSessionId());
const metadata = await probeFile(tmpInputPath);
const subtitleStreams = (metadata.streams || [])
.filter(s => s.codec_type === 'subtitle')
.map((s, idx) => ({
index: s.index,
subIndex: idx, // Index among subtitle streams
codec: s.codec_name,
language: s.tags?.language || 'und',
title: s.tags?.title || `Subtitle #${idx + 1} (${s.codec_name})`
}));
res.json({
duration: metadata.format?.duration,
subtitleStreams
});
} catch (error) {
console.error('Error fetching video metadata:', error);
res.status(500).json({ error: 'Failed to fetch video metadata', detail: error.message });
}
});
app.get('/api/hls/playlist.m3u8', async (req, res) => {
const bucket = req.query.bucket;
const key = req.query.key;
const subtitleIndex = req.query.subtitleIndex; // The subIndex (index among subtitle streams)
if (!bucket || !key) return res.status(400).send('Bad Request');
const tmpInputPath = getInputCachePath(bucket, key);
@@ -762,15 +807,16 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
}
const totalSegments = Math.ceil(duration / HLS_SEGMENT_TIME);
console.log(`[HLS] Generating m3u8: ${totalSegments} segments, duration=${duration}s, key=${key}`);
console.log(`[HLS] Generating m3u8: ${totalSegments} segments, duration=${duration}s, key=${key}, subtitleIndex=${subtitleIndex}`);
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`;
const subtitleParam = subtitleIndex !== undefined && subtitleIndex !== null && subtitleIndex !== '-1' ? `&subtitleIndex=${subtitleIndex}` : '';
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 += `#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'}${subtitleParam}\n`;
}
m3u8 += `#EXT-X-ENDLIST\n`;
@@ -805,15 +851,18 @@ app.get('/api/hls/segment.ts', async (req, res) => {
const seg = parseInt(req.query.seg || '0');
const requestedEncoder = req.query.encoder || 'h264_rkmpp';
const requestedDecoder = req.query.decoder || 'auto';
const subtitleIndex = req.query.subtitleIndex;
if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request');
console.log(`[HLS] Segment request: seg=${seg}, key=${key}, encoder=${requestedEncoder}`);
console.log(`[HLS] Segment request: seg=${seg}, key=${key}, encoder=${requestedEncoder}, sub=${subtitleIndex}`);
const tmpInputPath = getInputCachePath(bucket, key);
const progressKey = getProgressKey(key);
const hlsDir = getHlsCacheDir(bucket, key);
const baseProgressKey = getProgressKey(key);
const subtitleSuffix = (subtitleIndex !== null && subtitleIndex !== undefined && subtitleIndex !== '-1') ? `-sub${subtitleIndex}` : '';
const progressKey = `${baseProgressKey}${subtitleSuffix}`;
const hlsDir = getHlsCacheDir(bucket, key, subtitleIndex);
if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true });
const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`);
@@ -859,7 +908,8 @@ app.get('/api/hls/segment.ts', async (req, res) => {
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`);
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);
@@ -869,9 +919,49 @@ app.get('/api/hls/segment.ts', async (req, res) => {
ffmpegCommand.videoCodec(encoderName).audioCodec('aac');
const videoFilters = [];
if (isVaapiCodec(encoderName)) {
ffmpegCommand.inputOptions(['-vaapi_device', '/dev/dri/renderD128']).videoFilters('format=nv12,hwupload');
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]);
@@ -916,8 +1006,8 @@ app.get('/api/hls/segment.ts', async (req, res) => {
details: `处理进度 ${percent}%`,
mp4Url: null
};
progressMap[progressKey] = progressState;
broadcastWs(progressKey, { type: 'progress', key, progress: progressState });
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}%`);
});