添加内嵌字幕识别

This commit is contained in:
CN-JS-HuiBai
2026-04-03 22:12:41 +08:00
parent cd866b05df
commit dd0aa998cc
3 changed files with 318 additions and 53 deletions

207
server.js
View File

@@ -72,36 +72,33 @@ const transcodeProcesses = new Map();
const wsSubscriptions = new Map();
const AVAILABLE_VIDEO_ENCODERS = [
{ value: 'libx264', label: 'libx264 (Software H.264)' },
{ value: 'libx265', label: 'libx265 (Software H.265)' },
{ value: 'h264_nvenc', label: 'h264_nvenc (NVIDIA H.264)' },
{ value: 'hevc_nvenc', label: 'hevc_nvenc (NVIDIA HEVC)' },
{ value: 'h264_qsv', label: 'h264_qsv (Intel QSV H.264)' },
{ value: 'hevc_qsv', label: 'hevc_qsv (Intel QSV HEVC)' },
{ value: 'h264_vaapi', label: 'h264_vaapi (VAAPI H.264)' },
{ value: 'hevc_vaapi', label: 'hevc_vaapi (VAAPI HEVC)' },
{ value: 'h264_rkmpp', label: 'h264_rkmpp (RKMPP H.264)' },
{ value: 'hevc_rkmpp', label: 'hevc_rkmpp (RKMPP HEVC)' },
{ value: 'mjpeg_rkmpp', label: 'mjpeg_rkmpp (RKMPP MJPEG)' }
];
const AVAILABLE_VIDEO_DECODERS = [
{ value: 'auto', label: 'Auto Select Decoder' },
{ value: 'av1_rkmpp', label: 'av1_rkmpp (RKMPP AV1)' },
{ value: 'h263_rkmpp', label: 'h263_rkmpp (RKMPP H.263)' },
{ value: 'h264_rkmpp', label: 'h264_rkmpp (RKMPP H.264)' },
{ value: 'hevc_rkmpp', label: 'hevc_rkmpp (RKMPP HEVC)' },
{ value: 'mjpeg_rkmpp', label: 'mjpeg_rkmpp (RKMPP MJPEG)' },
{ value: 'mpeg1_rkmpp', label: 'mpeg1_rkmpp (RKMPP MPEG-1)' },
{ value: 'mpeg2_rkmpp', label: 'mpeg2_rkmpp (RKMPP MPEG-2)' },
{ value: 'mpeg4_rkmpp', label: 'mpeg4_rkmpp (RKMPP MPEG-4)' },
{ value: 'vp8_rkmpp', label: 'vp8_rkmpp (RKMPP VP8)' },
{ value: 'vp9_rkmpp', label: 'vp9_rkmpp (RKMPP VP9)' }
{ value: 'libx264', label: 'libx264 (Software / NEON H.264)' },
{ value: 'libx265', label: 'libx265 (Software / NEON H.265)' }
];
const AVAILABLE_VIDEO_DECODERS = [
{ value: 'auto', label: 'Auto Select Decoder' }
];
const SUPPORTED_TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'text', 'webvtt']);
const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/');
const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const getCachePathParts = (bucket, key) => {
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
const progressKey = safeKeySegments.join('/');
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
return {
progressKey,
safeKeySegments,
tmpInputPath
};
};
const addWsClient = (progressKey, ws) => {
if (!wsSubscriptions.has(progressKey)) {
@@ -220,6 +217,73 @@ const shouldRetryWithSoftware = (message) => {
return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument|mpp_create|rkmpp/i.test(message);
};
const ensureSourceCached = async ({ s3Client, bucket, key, tmpInputPath, onProgress }) => {
if (fs.existsSync(tmpInputPath)) {
const stats = fs.statSync(tmpInputPath);
return {
totalBytes: stats.size,
downloadedBytes: stats.size,
cacheExists: true
};
}
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;
if (typeof onProgress === 'function') {
onProgress({ totalBytes, downloadedBytes, cacheExists: false });
}
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(tmpInputPath);
s3Stream.on('data', (chunk) => {
downloadedBytes += chunk.length;
if (typeof onProgress === 'function') {
onProgress({ totalBytes, downloadedBytes, cacheExists: false });
}
});
s3Stream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
s3Stream.pipe(writeStream);
});
return {
totalBytes,
downloadedBytes,
cacheExists: false
};
};
const getSubtitleTracks = (metadata) => {
return (metadata?.streams || [])
.filter((stream) => stream.codec_type === 'subtitle')
.map((stream, orderIndex) => {
const codec = (stream.codec_name || '').toLowerCase();
const language = stream.tags?.language || '';
const title = stream.tags?.title || '';
const supported = SUPPORTED_TEXT_SUBTITLE_CODECS.has(codec);
const labelParts = [
language ? language.toUpperCase() : `Subtitle ${orderIndex + 1}`,
title || null,
codec ? codec : null,
supported ? null : 'Unsupported'
].filter(Boolean);
return {
index: stream.index,
codec,
language,
title,
supported,
label: labelParts.join(' / ')
};
});
};
const probeFile = (filePath) => {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(filePath, (err, metadata) => {
@@ -410,6 +474,75 @@ app.get('/api/config', (req, res) => {
});
});
app.get('/api/subtitles', async (req, res) => {
const bucket = req.query.bucket;
const key = req.query.key;
const requestedStreamIndex = typeof req.query.streamIndex === 'string' ? req.query.streamIndex.trim() : '';
const startSeconds = Math.max(0, parseFloat(req.query.ss) || 0);
if (!bucket) {
return res.status(400).json({ error: 'Bucket name is required' });
}
if (!key) {
return res.status(400).json({ error: 'Video key is required' });
}
const { tmpInputPath } = getCachePathParts(bucket, key);
const auth = extractS3Credentials(req);
const s3Client = createS3Client(auth);
try {
await ensureSourceCached({ s3Client, bucket, key, tmpInputPath });
const metadata = await probeFile(tmpInputPath);
const tracks = getSubtitleTracks(metadata);
if (!requestedStreamIndex) {
return res.json({ tracks });
}
const numericStreamIndex = parseInt(requestedStreamIndex, 10);
const matchedTrack = tracks.find((track) => track.index === numericStreamIndex);
if (!matchedTrack) {
return res.status(404).json({ error: 'Subtitle track not found' });
}
if (!matchedTrack.supported) {
return res.status(415).json({ error: 'Subtitle codec is not supported for WebVTT output', codec: matchedTrack.codec });
}
res.setHeader('Content-Type', 'text/vtt; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
const subtitleCommand = ffmpeg()
.input(tmpInputPath)
.outputOptions(['-map', `0:${numericStreamIndex}`, '-reset_timestamps', '1'])
.format('webvtt')
.noAudio()
.noVideo();
if (startSeconds > 0) {
if (typeof subtitleCommand.seekInput === 'function') {
subtitleCommand.seekInput(startSeconds);
} else {
subtitleCommand.inputOptions(['-ss', startSeconds.toString()]);
}
}
subtitleCommand
.on('error', (error) => {
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to extract subtitles', detail: error.message });
} else {
res.destroy(error);
}
})
.pipe(res, { end: true });
} catch (error) {
console.error('Error serving subtitles:', error);
res.status(500).json({ error: 'Failed to load subtitles', detail: error.message });
}
});
app.post('/api/clear-download-cache', (req, res) => {
try {
clearDownloadCache();
@@ -466,11 +599,7 @@ app.get('/api/stream', async (req, res) => {
const videoEncoder = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
const requestedVideoDecoder = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
const progressKey = safeKeySegments.join('/');
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
const cacheExists = fs.existsSync(tmpInputPath);
const { progressKey, tmpInputPath } = getCachePathParts(bucket, key);
const auth = extractS3Credentials(req);
const s3Client = createS3Client(auth);
@@ -492,13 +621,9 @@ app.get('/api/stream', async (req, res) => {
let totalBytes = 0;
let downloadedBytes = 0;
const cacheExists = fs.existsSync(tmpInputPath);
if (!cacheExists) {
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
const response = await s3Client.send(command);
const s3Stream = response.Body;
totalBytes = response.ContentLength || 0;
progressMap[progressKey] = {
status: 'downloading',
percent: 0,
@@ -510,10 +635,14 @@ app.get('/api/stream', async (req, res) => {
};
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(tmpInputPath);
s3Stream.on('data', (chunk) => {
downloadedBytes += chunk.length;
await ensureSourceCached({
s3Client,
bucket,
key,
tmpInputPath,
onProgress: ({ totalBytes: nextTotalBytes, downloadedBytes: nextDownloadedBytes }) => {
totalBytes = nextTotalBytes;
downloadedBytes = nextDownloadedBytes;
const percent = totalBytes ? Math.min(100, Math.round(downloadedBytes / totalBytes * 100)) : 0;
const downloadState = {
status: 'downloading',
@@ -526,11 +655,7 @@ app.get('/api/stream', async (req, res) => {
};
progressMap[progressKey] = downloadState;
broadcastWs(progressKey, { type: 'progress', key, progress: downloadState });
});
s3Stream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
s3Stream.pipe(writeStream);
}
});
progressMap[progressKey] = {