将视频字幕嵌入
This commit is contained in:
@@ -647,6 +647,50 @@ header p {
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.now-playing-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-panel label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-panel select {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-panel select option {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.now-playing p {
|
.now-playing p {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -140,6 +140,12 @@
|
|||||||
<h3>Now Playing</h3>
|
<h3>Now Playing</h3>
|
||||||
<p id="current-video-title">video.mp4</p>
|
<p id="current-video-title">video.mp4</p>
|
||||||
<div class="now-playing-actions">
|
<div class="now-playing-actions">
|
||||||
|
<div id="subtitle-panel" class="subtitle-panel hidden">
|
||||||
|
<label for="subtitle-selector">字幕:</label>
|
||||||
|
<select id="subtitle-selector">
|
||||||
|
<option value="-1">无字幕</option>
|
||||||
|
</select>
|
||||||
|
</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="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>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const themeSelector = document.getElementById('theme-selector');
|
const themeSelector = document.getElementById('theme-selector');
|
||||||
const videoListHeader = document.getElementById('video-list-header');
|
const videoListHeader = document.getElementById('video-list-header');
|
||||||
const logoutBtn = document.getElementById('logout-btn');
|
const logoutBtn = document.getElementById('logout-btn');
|
||||||
|
const subtitlePanel = document.getElementById('subtitle-panel');
|
||||||
|
const subtitleSelector = document.getElementById('subtitle-selector');
|
||||||
const topBannerTitle = document.getElementById('top-banner-title');
|
const topBannerTitle = document.getElementById('top-banner-title');
|
||||||
const playBtn = document.getElementById('play-btn');
|
const playBtn = document.getElementById('play-btn');
|
||||||
const topBanner = document.getElementById('top-banner');
|
const topBanner = document.getElementById('top-banner');
|
||||||
@@ -143,6 +145,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const decoder = 'auto';
|
const decoder = 'auto';
|
||||||
const encoder = encoderSelect?.value || 'h264_rkmpp';
|
const encoder = encoderSelect?.value || 'h264_rkmpp';
|
||||||
let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`;
|
let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`;
|
||||||
|
|
||||||
|
const subIndex = subtitleSelector?.value;
|
||||||
|
if (subIndex && subIndex !== '-1') {
|
||||||
|
streamUrl += `&subtitleIndex=${encodeURIComponent(subIndex)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionId = localStorage.getItem('sessionId');
|
const sessionId = localStorage.getItem('sessionId');
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
streamUrl += `&sessionId=${encodeURIComponent(sessionId)}`;
|
streamUrl += `&sessionId=${encodeURIComponent(sessionId)}`;
|
||||||
@@ -265,16 +273,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const subscribeToKey = (key) => {
|
const subscribeToKey = (key) => {
|
||||||
subscribedKey = key;
|
let subscriptionKey = key;
|
||||||
|
const subIndex = subtitleSelector?.value;
|
||||||
|
if (subIndex && subIndex !== '-1') {
|
||||||
|
subscriptionKey = `${key}-sub${subIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribedKey = subscriptionKey;
|
||||||
if (wsConnected) {
|
if (wsConnected) {
|
||||||
sendWsMessage({ type: 'subscribe', key });
|
sendWsMessage({ type: 'subscribe', key: subscriptionKey });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWsMessage = (event) => {
|
const handleWsMessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
if (message.key !== currentVideoKey) return;
|
if (message.key !== subscribedKey) return;
|
||||||
|
|
||||||
if (message.type === 'duration' && message.duration) {
|
if (message.type === 'duration' && message.duration) {
|
||||||
videoDuration = message.duration;
|
videoDuration = message.duration;
|
||||||
@@ -891,6 +905,51 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
nowPlaying.classList.remove('hidden');
|
nowPlaying.classList.remove('hidden');
|
||||||
currentVideoTitle.textContent = key.split('/').pop();
|
currentVideoTitle.textContent = key.split('/').pop();
|
||||||
|
|
||||||
|
// Fetch subtitle metadata
|
||||||
|
fetchVideoMetadata(selectedBucket, key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubtitleChange = () => {
|
||||||
|
if (!selectedKey) return;
|
||||||
|
subscribeToKey(selectedKey);
|
||||||
|
};
|
||||||
|
if (subtitleSelector) {
|
||||||
|
subtitleSelector.addEventListener('change', handleSubtitleChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchVideoMetadata = async (bucket, key) => {
|
||||||
|
if (!subtitlePanel || !subtitleSelector) return;
|
||||||
|
|
||||||
|
subtitlePanel.classList.add('hidden');
|
||||||
|
subtitleSelector.innerHTML = '<option value="-1">正在加载字幕...</option>';
|
||||||
|
subtitleSelector.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/video-metadata?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}`, { headers: s3AuthHeaders });
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch metadata');
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
subtitleSelector.innerHTML = '<option value="-1">无字幕</option>';
|
||||||
|
if (data.subtitleStreams && data.subtitleStreams.length > 0) {
|
||||||
|
data.subtitleStreams.forEach(sub => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = sub.subIndex;
|
||||||
|
option.textContent = `[${sub.language}] ${sub.title}`;
|
||||||
|
subtitleSelector.appendChild(option);
|
||||||
|
});
|
||||||
|
subtitlePanel.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
subtitleSelector.innerHTML = '<option value="-1">无嵌入字幕</option>';
|
||||||
|
subtitlePanel.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch metadata failed:', err);
|
||||||
|
subtitleSelector.innerHTML = '<option value="-1">无法加载字幕</option>';
|
||||||
|
subtitlePanel.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
subtitleSelector.disabled = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTranscode = async () => {
|
const startTranscode = async () => {
|
||||||
|
|||||||
118
server.js
118
server.js
@@ -159,10 +159,11 @@ const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[
|
|||||||
|
|
||||||
const makeSafeName = (name) => name.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
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 safeBucket = makeSafeName(bucket);
|
||||||
const safeKey = key.split('/').map(makeSafeName).join('-');
|
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) => {
|
const getInputCachePath = (bucket, key) => {
|
||||||
@@ -649,10 +650,19 @@ app.post('/api/clear-video-transcode-cache', async (req, res) => {
|
|||||||
if (!bucket || !key) {
|
if (!bucket || !key) {
|
||||||
return res.status(400).json({ error: 'Bucket and key are required' });
|
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)) {
|
const files = fs.readdirSync(CACHE_DIR);
|
||||||
fs.rmSync(hlsDir, { recursive: true, force: true });
|
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' });
|
res.json({ message: 'Transcode cache cleared for video' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -727,9 +737,44 @@ const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => {
|
|||||||
return false;
|
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) => {
|
app.get('/api/hls/playlist.m3u8', async (req, res) => {
|
||||||
const bucket = req.query.bucket;
|
const bucket = req.query.bucket;
|
||||||
const key = req.query.key;
|
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');
|
if (!bucket || !key) return res.status(400).send('Bad Request');
|
||||||
|
|
||||||
const tmpInputPath = getInputCachePath(bucket, key);
|
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);
|
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`;
|
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++) {
|
for (let i = 0; i < totalSegments; i++) {
|
||||||
let segDur = HLS_SEGMENT_TIME;
|
let segDur = HLS_SEGMENT_TIME;
|
||||||
if (i === totalSegments - 1 && duration % HLS_SEGMENT_TIME !== 0) {
|
if (i === totalSegments - 1 && duration % HLS_SEGMENT_TIME !== 0) {
|
||||||
segDur = (duration % HLS_SEGMENT_TIME) || HLS_SEGMENT_TIME;
|
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`;
|
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 seg = parseInt(req.query.seg || '0');
|
||||||
const requestedEncoder = req.query.encoder || 'h264_rkmpp';
|
const requestedEncoder = req.query.encoder || 'h264_rkmpp';
|
||||||
const requestedDecoder = req.query.decoder || 'auto';
|
const requestedDecoder = req.query.decoder || 'auto';
|
||||||
|
const subtitleIndex = req.query.subtitleIndex;
|
||||||
|
|
||||||
if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request');
|
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 tmpInputPath = getInputCachePath(bucket, key);
|
||||||
|
|
||||||
const progressKey = getProgressKey(key);
|
const baseProgressKey = getProgressKey(key);
|
||||||
const hlsDir = getHlsCacheDir(bucket, 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 });
|
if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true });
|
||||||
|
|
||||||
const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`);
|
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 encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
|
||||||
const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
|
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`);
|
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
|
||||||
if (fs.existsSync(m3u8Path)) fs.unlinkSync(m3u8Path);
|
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');
|
ffmpegCommand.videoCodec(encoderName).audioCodec('aac');
|
||||||
|
|
||||||
|
const videoFilters = [];
|
||||||
if (isVaapiCodec(encoderName)) {
|
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;
|
const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName) ? getRkmppDecoderName(sourceMetadata) : decoderName;
|
||||||
if (resolvedDecoderName && resolvedDecoderName !== 'auto') ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]);
|
if (resolvedDecoderName && resolvedDecoderName !== 'auto') ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]);
|
||||||
|
|
||||||
@@ -916,8 +1006,8 @@ app.get('/api/hls/segment.ts', async (req, res) => {
|
|||||||
details: `处理进度 ${percent}%`,
|
details: `处理进度 ${percent}%`,
|
||||||
mp4Url: null
|
mp4Url: null
|
||||||
};
|
};
|
||||||
progressMap[progressKey] = progressState;
|
const broadcastKey = subtitleIndex && subtitleIndex !== '-1' ? `${key}-sub${subtitleIndex}` : key;
|
||||||
broadcastWs(progressKey, { type: 'progress', key, progress: progressState });
|
broadcastWs(progressKey, { type: 'progress', key: broadcastKey, progress: progressState });
|
||||||
|
|
||||||
console.log(`[FFmpeg] ${progressKey} | ${progress.timemark} | ${sanitizeNumber(progress.currentFps) ?? '-'}fps | ${sanitizeNumber(progress.currentKbps) ?? '-'}kbps | ${percent}%`);
|
console.log(`[FFmpeg] ${progressKey} | ${progress.timemark} | ${sanitizeNumber(progress.currentFps) ?? '-'}fps | ${sanitizeNumber(progress.currentKbps) ?? '-'}kbps | ${percent}%`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user