添加内嵌字幕识别
This commit is contained in:
207
server.js
207
server.js
@@ -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] = {
|
||||
|
||||
Reference in New Issue
Block a user