Compare commits
13 Commits
a6af3765a8
...
Rockchip
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f09644125 | |||
| 72900de4ed | |||
| cc40f1920c | |||
| 23c052bc76 | |||
| e0adb66713 | |||
|
|
fce0e3d581 | ||
|
|
a33cf44de0 | ||
|
|
be953b1621 | ||
|
|
116db4cb0f | ||
|
|
97a339f08d | ||
|
|
888ca621e4 | ||
|
|
df5999c338 | ||
|
|
3b65c306de |
@@ -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;
|
||||||
@@ -1045,13 +1089,21 @@ header p {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-btn {
|
.now-playing-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.85rem 1.25rem;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-btn {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-size: 0.92rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.2s ease, background 0.2s ease;
|
transition: transform 0.2s ease, background 0.2s ease;
|
||||||
@@ -1157,4 +1209,67 @@ header p {
|
|||||||
.control-seek {
|
.control-seek {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile specific top bar */
|
||||||
|
.user-controls {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
z-index: 1000;
|
||||||
|
border-bottom: 1px solid var(--panel-border);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-controls label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#theme-selector {
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-controls .banner-actions,
|
||||||
|
.user-controls .user-info {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-controls .action-btn {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding-top: 5rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-banner {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -139,8 +139,19 @@
|
|||||||
<div id="now-playing" class="now-playing hidden">
|
<div id="now-playing" class="now-playing hidden">
|
||||||
<h3>Now Playing</h3>
|
<h3>Now Playing</h3>
|
||||||
<p id="current-video-title">video.mp4</p>
|
<p id="current-video-title">video.mp4</p>
|
||||||
<button id="transcode-btn" class="play-btn hidden">Start Transcode</button>
|
<div class="now-playing-actions">
|
||||||
<button id="stop-transcode-btn" class="play-btn stop-btn hidden">Stop Transcode</button>
|
<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="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-transcode-cache-btn" class="play-btn stop-btn hidden">清空转码缓存</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ 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 clearPlayingTranscodeBtn = document.getElementById('clear-playing-transcode-cache-btn');
|
||||||
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');
|
||||||
@@ -141,6 +146,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)}`;
|
||||||
@@ -263,16 +274,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;
|
||||||
@@ -379,7 +396,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (stopTranscodeBtn) {
|
if (stopTranscodeBtn) {
|
||||||
stopTranscodeBtn.classList.add('hidden');
|
stopTranscodeBtn.classList.add('hidden');
|
||||||
stopTranscodeBtn.disabled = false;
|
stopTranscodeBtn.disabled = false;
|
||||||
stopTranscodeBtn.textContent = 'Stop Transcode';
|
stopTranscodeBtn.textContent = '停止播放';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -461,6 +478,34 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
seekToTime(targetTime);
|
seekToTime(targetTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const seekToTime = (targetTime) => {
|
||||||
|
if (!isStreamActive || videoDuration <= 0) return;
|
||||||
|
const clampedTime = Math.max(0, Math.min(targetTime, videoDuration));
|
||||||
|
console.log(`[Seek] Seeking to ${clampedTime.toFixed(2)}s / ${videoDuration.toFixed(2)}s`);
|
||||||
|
|
||||||
|
if (hlsInstance) {
|
||||||
|
// For HLS: seek within the current buffer if possible
|
||||||
|
const relativeTime = clampedTime - seekOffset;
|
||||||
|
if (relativeTime >= 0 && relativeTime <= (videoPlayer.duration || 0)) {
|
||||||
|
videoPlayer.currentTime = relativeTime;
|
||||||
|
} else {
|
||||||
|
// Need server-side restart from new position
|
||||||
|
seekOffset = clampedTime;
|
||||||
|
const streamUrl = buildHlsPlaylistUrl();
|
||||||
|
hlsInstance.destroy();
|
||||||
|
hlsInstance = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 });
|
||||||
|
hlsInstance.loadSource(streamUrl);
|
||||||
|
hlsInstance.attachMedia(videoPlayer);
|
||||||
|
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
videoPlayer.play().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoPlayer.currentTime = clampedTime;
|
||||||
|
}
|
||||||
|
updateSeekBarPosition(clampedTime);
|
||||||
|
};
|
||||||
|
|
||||||
if (seekBar) {
|
if (seekBar) {
|
||||||
seekBar.addEventListener('mousedown', handleSeekStart);
|
seekBar.addEventListener('mousedown', handleSeekStart);
|
||||||
document.addEventListener('mousemove', handleSeekMove);
|
document.addEventListener('mousemove', handleSeekMove);
|
||||||
@@ -724,21 +769,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (index === parts.length - 1) {
|
if (index === parts.length - 1) {
|
||||||
current[part].__file = key;
|
current[part].__file = key;
|
||||||
current[part].__hasTranscodeCache = vid.hasTranscodeCache;
|
current[part].__hasTranscodeCache = vid.hasTranscodeCache;
|
||||||
|
current[part].__hasDownloadCache = vid.hasDownloadCache;
|
||||||
}
|
}
|
||||||
current = current[part];
|
current = current[part];
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const createFileItem = (name, key, hasTranscodeCache) => {
|
const createFileItem = (name, key, hasTranscodeCache, hasDownloadCache) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'video-item file-item';
|
li.className = 'video-item file-item';
|
||||||
const ext = name.split('.').pop().toUpperCase();
|
const ext = name.split('.').pop().toUpperCase();
|
||||||
|
|
||||||
let cacheButtonHtml = '';
|
|
||||||
if (hasTranscodeCache) {
|
|
||||||
cacheButtonHtml = `<button class="action-btn danger clear-video-cache-btn" data-key="${key}" style="padding: 0.3rem 0.6rem; font-size: 0.75rem; margin-left: auto;" title="清空转码缓存">清空转码缓存</button>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<div class="video-icon">
|
<div class="video-icon">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg>
|
||||||
@@ -747,44 +788,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<div class="video-title" title="${key}">${name}</div>
|
<div class="video-title" title="${key}">${name}</div>
|
||||||
<div class="video-meta">Video / ${ext}</div>
|
<div class="video-meta">Video / ${ext}</div>
|
||||||
</div>
|
</div>
|
||||||
${cacheButtonHtml}
|
|
||||||
`;
|
`;
|
||||||
li.addEventListener('click', (e) => {
|
li.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
selectVideo(key, li);
|
selectVideo(key, li, hasTranscodeCache, hasDownloadCache);
|
||||||
});
|
});
|
||||||
|
|
||||||
const clearBtn = li.querySelector('.clear-video-cache-btn');
|
|
||||||
if (clearBtn) {
|
|
||||||
clearBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
clearBtn.disabled = true;
|
|
||||||
clearBtn.textContent = '清空中...';
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/clear-video-transcode-cache', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ bucket: selectedBucket, key: key })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to clear cache');
|
|
||||||
fetchVideos(selectedBucket);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert('清空转码缓存失败: ' + err.message);
|
|
||||||
clearBtn.disabled = false;
|
|
||||||
clearBtn.textContent = '清空转码缓存';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return li;
|
return li;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderTree = (node, container) => {
|
const renderTree = (node, container) => {
|
||||||
for (const [name, value] of Object.entries(node)) {
|
for (const [name, value] of Object.entries(node)) {
|
||||||
if (name === '__file' || name === '__hasTranscodeCache') continue;
|
if (name === '__file' || name === '__hasTranscodeCache' || name === '__hasDownloadCache') continue;
|
||||||
|
|
||||||
const childKeys = Object.keys(value).filter(k => k !== '__file' && k !== '__hasTranscodeCache');
|
const childKeys = Object.keys(value).filter(k => k !== '__file' && k !== '__hasTranscodeCache' && k !== '__hasDownloadCache');
|
||||||
const hasChildren = childKeys.length > 0;
|
const hasChildren = childKeys.length > 0;
|
||||||
const isFile = typeof value.__file === 'string';
|
const isFile = typeof value.__file === 'string';
|
||||||
|
|
||||||
@@ -815,13 +832,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
li.appendChild(folderHeader);
|
li.appendChild(folderHeader);
|
||||||
if (isFile) {
|
if (isFile) {
|
||||||
subListContainer.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache));
|
subListContainer.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache, value.__hasDownloadCache));
|
||||||
}
|
}
|
||||||
renderTree(value, subListContainer);
|
renderTree(value, subListContainer);
|
||||||
li.appendChild(subListContainer);
|
li.appendChild(subListContainer);
|
||||||
container.appendChild(li);
|
container.appendChild(li);
|
||||||
} else if (isFile) {
|
} else if (isFile) {
|
||||||
container.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache));
|
container.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache, value.__hasDownloadCache));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -834,7 +851,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle video selection
|
// Handle video selection
|
||||||
const selectVideo = (key, listItemNode) => {
|
const selectVideo = (key, listItemNode, hasTranscodeCache = false, hasDownloadCache = false) => {
|
||||||
document.querySelectorAll('.video-item').forEach(n => n.classList.remove('active'));
|
document.querySelectorAll('.video-item').forEach(n => n.classList.remove('active'));
|
||||||
listItemNode.classList.add('active');
|
listItemNode.classList.add('active');
|
||||||
|
|
||||||
@@ -860,37 +877,111 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
if (transcodeBtn) {
|
if (transcodeBtn) {
|
||||||
transcodeBtn.disabled = false;
|
transcodeBtn.disabled = false;
|
||||||
transcodeBtn.textContent = 'Start Transcode';
|
transcodeBtn.textContent = '开始播放';
|
||||||
transcodeBtn.classList.remove('hidden');
|
transcodeBtn.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
if (stopTranscodeBtn) {
|
if (stopTranscodeBtn) {
|
||||||
stopTranscodeBtn.classList.add('hidden');
|
stopTranscodeBtn.classList.add('hidden');
|
||||||
stopTranscodeBtn.disabled = false;
|
stopTranscodeBtn.disabled = false;
|
||||||
stopTranscodeBtn.textContent = 'Stop Transcode';
|
stopTranscodeBtn.textContent = '停止播放';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preSliceBtn) {
|
||||||
|
preSliceBtn.classList.remove('hidden');
|
||||||
|
preSliceBtn.disabled = false;
|
||||||
|
}
|
||||||
|
if (clearPlayingDownloadBtn) {
|
||||||
|
if (hasDownloadCache) clearPlayingDownloadBtn.classList.remove('hidden');
|
||||||
|
else clearPlayingDownloadBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (clearPlayingTranscodeBtn) {
|
||||||
|
if (hasTranscodeCache) clearPlayingTranscodeBtn.classList.remove('hidden');
|
||||||
|
else clearPlayingTranscodeBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
if (playBtn) {
|
if (playBtn) {
|
||||||
playBtn.classList.add('hidden');
|
playBtn.classList.add('hidden');
|
||||||
}
|
}
|
||||||
resetPhases();
|
resetPhases();
|
||||||
|
|
||||||
|
// Reset subtitle selector before subscribing to ensure we use the base key for source download
|
||||||
|
if (subtitleSelector) {
|
||||||
|
subtitleSelector.innerHTML = '<option value="-1">无字幕</option>';
|
||||||
|
subtitleSelector.value = "-1";
|
||||||
|
}
|
||||||
|
if (subtitlePanel) {
|
||||||
|
subtitlePanel.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
selectedKey = key;
|
selectedKey = key;
|
||||||
currentVideoKey = key;
|
currentVideoKey = key;
|
||||||
subscribeToKey(key);
|
subscribeToKey(key);
|
||||||
|
|
||||||
nowPlaying.classList.remove('hidden');
|
nowPlaying.classList.remove('hidden');
|
||||||
currentVideoTitle.textContent = key.split('/').pop();
|
currentVideoTitle.textContent = key.split('/').pop();
|
||||||
|
|
||||||
|
// If download is needed, show the overlay so the user sees the progress
|
||||||
|
if (!hasDownloadCache) {
|
||||||
|
transcodingOverlay.classList.remove('hidden');
|
||||||
|
showDownloadPhase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 () => {
|
||||||
if (!selectedKey) return;
|
if (!selectedKey) return;
|
||||||
if (transcodeBtn) {
|
if (transcodeBtn) {
|
||||||
transcodeBtn.disabled = true;
|
transcodeBtn.disabled = true;
|
||||||
transcodeBtn.textContent = 'Starting...';
|
transcodeBtn.textContent = '播放中...';
|
||||||
}
|
}
|
||||||
if (stopTranscodeBtn) {
|
if (stopTranscodeBtn) {
|
||||||
stopTranscodeBtn.classList.remove('hidden');
|
stopTranscodeBtn.classList.remove('hidden');
|
||||||
stopTranscodeBtn.disabled = false;
|
stopTranscodeBtn.disabled = false;
|
||||||
stopTranscodeBtn.textContent = 'Stop Transcode';
|
stopTranscodeBtn.textContent = '停止播放';
|
||||||
}
|
}
|
||||||
stopPolling();
|
stopPolling();
|
||||||
resetPhases();
|
resetPhases();
|
||||||
@@ -914,10 +1005,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
hlsInstance.loadSource(streamUrl);
|
hlsInstance.loadSource(streamUrl);
|
||||||
hlsInstance.attachMedia(videoPlayer);
|
hlsInstance.attachMedia(videoPlayer);
|
||||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
console.log('[HLS] MANIFEST_PARSED - starting playback');
|
||||||
transcodingOverlay.classList.add('hidden');
|
transcodingOverlay.classList.add('hidden');
|
||||||
videoPlayer.classList.remove('hidden');
|
videoPlayer.classList.remove('hidden');
|
||||||
isStreamActive = true;
|
isStreamActive = true;
|
||||||
videoPlayer.play().catch(() => { });
|
videoPlayer.play().catch((e) => {
|
||||||
|
console.warn('[HLS] Autoplay blocked:', e.message);
|
||||||
|
});
|
||||||
showSeekBar();
|
showSeekBar();
|
||||||
showCustomControls();
|
showCustomControls();
|
||||||
updatePlayControls();
|
updatePlayControls();
|
||||||
@@ -926,15 +1020,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
schedulePlaybackChromeHide();
|
schedulePlaybackChromeHide();
|
||||||
});
|
});
|
||||||
hlsInstance.on(Hls.Events.ERROR, function (event, data) {
|
hlsInstance.on(Hls.Events.ERROR, function (event, data) {
|
||||||
|
console.error('[HLS] Error:', data.type, data.details, data.fatal ? '(FATAL)' : '', data);
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
console.warn('[HLS] Fatal network error, attempting recovery...');
|
||||||
hlsInstance.startLoad();
|
hlsInstance.startLoad();
|
||||||
break;
|
break;
|
||||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
console.warn('[HLS] Fatal media error, attempting recovery...');
|
||||||
hlsInstance.recoverMediaError();
|
hlsInstance.recoverMediaError();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
console.error('[HLS] Fatal error, destroying instance');
|
||||||
hlsInstance.destroy();
|
hlsInstance.destroy();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -964,7 +1062,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const stopTranscode = async () => {
|
const stopTranscode = async () => {
|
||||||
if (!currentVideoKey || !stopTranscodeBtn) return;
|
if (!currentVideoKey || !stopTranscodeBtn) return;
|
||||||
stopTranscodeBtn.disabled = true;
|
stopTranscodeBtn.disabled = true;
|
||||||
stopTranscodeBtn.textContent = 'Stopping...';
|
stopTranscodeBtn.textContent = '停止中...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/stop-transcode', {
|
const res = await fetch('/api/stop-transcode', {
|
||||||
@@ -990,21 +1088,59 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
updatePlayControls();
|
updatePlayControls();
|
||||||
if (transcodeBtn) {
|
if (transcodeBtn) {
|
||||||
transcodeBtn.disabled = false;
|
transcodeBtn.disabled = false;
|
||||||
transcodeBtn.textContent = 'Start Transcode';
|
transcodeBtn.textContent = '开始播放';
|
||||||
transcodeBtn.classList.remove('hidden');
|
transcodeBtn.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
stopTranscodeBtn.classList.add('hidden');
|
stopTranscodeBtn.classList.add('hidden');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Stop transcode failed:', err);
|
console.error('Stop transcode failed:', err);
|
||||||
alert(`Stop transcode failed: ${err.message}`);
|
alert(`停止播放失败: ${err.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (stopTranscodeBtn) {
|
if (stopTranscodeBtn) {
|
||||||
stopTranscodeBtn.disabled = false;
|
stopTranscodeBtn.disabled = false;
|
||||||
stopTranscodeBtn.textContent = 'Stop Transcode';
|
stopTranscodeBtn.textContent = '停止播放';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -1116,6 +1252,52 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (stopTranscodeBtn) {
|
if (stopTranscodeBtn) {
|
||||||
stopTranscodeBtn.addEventListener('click', stopTranscode);
|
stopTranscodeBtn.addEventListener('click', stopTranscode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clearPlayingDownloadBtn) {
|
||||||
|
clearPlayingDownloadBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentVideoKey) return;
|
||||||
|
clearPlayingDownloadBtn.disabled = true;
|
||||||
|
clearPlayingDownloadBtn.textContent = '清空中...';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/clear-video-download-cache', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bucket: selectedBucket, key: currentVideoKey })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('清空失败');
|
||||||
|
clearPlayingDownloadBtn.classList.add('hidden');
|
||||||
|
fetchVideos(selectedBucket);
|
||||||
|
} catch (err) {
|
||||||
|
alert('清空下载缓存失败: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
clearPlayingDownloadBtn.disabled = false;
|
||||||
|
clearPlayingDownloadBtn.textContent = '清空下载缓存';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearPlayingTranscodeBtn) {
|
||||||
|
clearPlayingTranscodeBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentVideoKey) return;
|
||||||
|
clearPlayingTranscodeBtn.disabled = true;
|
||||||
|
clearPlayingTranscodeBtn.textContent = '清空中...';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/clear-video-transcode-cache', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bucket: selectedBucket, key: currentVideoKey })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('清空失败');
|
||||||
|
clearPlayingTranscodeBtn.classList.add('hidden');
|
||||||
|
fetchVideos(selectedBucket);
|
||||||
|
} catch (err) {
|
||||||
|
alert('清空转码缓存失败: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
clearPlayingTranscodeBtn.disabled = false;
|
||||||
|
clearPlayingTranscodeBtn.textContent = '清空转码缓存';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
if (loginBtn) {
|
if (loginBtn) {
|
||||||
loginBtn.addEventListener('click', login);
|
loginBtn.addEventListener('click', login);
|
||||||
}
|
}
|
||||||
|
|||||||
522
server.js
522
server.js
@@ -157,6 +157,21 @@ const AVAILABLE_VIDEO_DECODERS = [
|
|||||||
|
|
||||||
const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/');
|
const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/');
|
||||||
|
|
||||||
|
const makeSafeName = (name) => name.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||||
|
|
||||||
|
const getHlsCacheDir = (bucket, key, subtitleIndex = null) => {
|
||||||
|
const safeBucket = makeSafeName(bucket);
|
||||||
|
const safeKey = key.split('/').map(makeSafeName).join('-');
|
||||||
|
const subSuffix = (subtitleIndex !== null && subtitleIndex !== undefined && subtitleIndex !== '-1') ? `-sub${subtitleIndex}` : '';
|
||||||
|
return path.join(CACHE_DIR, `hls-${safeBucket}-${safeKey}${subSuffix}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInputCachePath = (bucket, key) => {
|
||||||
|
const safeBucket = makeSafeName(bucket);
|
||||||
|
const safeKey = key.split('/').map(makeSafeName).join('-');
|
||||||
|
return path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKey}.tmp`);
|
||||||
|
};
|
||||||
|
|
||||||
const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
|
||||||
const addWsClient = (progressKey, ws) => {
|
const addWsClient = (progressKey, ws) => {
|
||||||
@@ -177,14 +192,34 @@ const removeWsClient = (ws) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const broadcastWs = (key, payload) => {
|
const broadcastWs = (key, payload) => {
|
||||||
|
// Broadcast to the specific room
|
||||||
const clients = wsSubscriptions.get(key);
|
const clients = wsSubscriptions.get(key);
|
||||||
if (!clients) return;
|
|
||||||
const message = JSON.stringify(payload);
|
const message = JSON.stringify(payload);
|
||||||
|
if (clients) {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(message);
|
client.send(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a base key (not a sub-key), also broadcast to all its sub-keys
|
||||||
|
if (!key.includes('-sub')) {
|
||||||
|
// Find the original raw key from the payload to reconstruct sub-keys
|
||||||
|
const rawKey = payload.key;
|
||||||
|
for (const [subRoomId, subClients] of wsSubscriptions.entries()) {
|
||||||
|
if (subRoomId.startsWith(`${key}-sub`)) {
|
||||||
|
// Extract the suffix from the room ID
|
||||||
|
const suffix = subRoomId.substring(key.length); // e.g., "-sub1"
|
||||||
|
for (const client of subClients) {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
const subPayload = { ...payload, key: `${rawKey}${suffix}` };
|
||||||
|
client.send(JSON.stringify(subPayload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createFfmpegOptions = (encoderName) => {
|
const createFfmpegOptions = (encoderName) => {
|
||||||
@@ -276,9 +311,13 @@ 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);
|
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 probeFile = (filePath) => {
|
const probeFile = (filePath, timeoutMs = 15000) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error(`ffprobe timed out after ${timeoutMs}ms for ${filePath}`));
|
||||||
|
}, timeoutMs);
|
||||||
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
||||||
|
clearTimeout(timer);
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve(metadata);
|
else resolve(metadata);
|
||||||
});
|
});
|
||||||
@@ -308,8 +347,15 @@ const parseTimemarkToSeconds = (timemark) => {
|
|||||||
return (hours * 3600) + (minutes * 60) + seconds;
|
return (hours * 3600) + (minutes * 60) + seconds;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopActiveTranscode = (progressKey) => {
|
const sanitizeNumber = (value) => {
|
||||||
const activeProcess = transcodeProcesses.get(progressKey);
|
if (value === null || value === undefined) return null;
|
||||||
|
const num = Number(value);
|
||||||
|
return Number.isFinite(num) ? num : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopActiveTranscode = (progressKey, sessionId = null) => {
|
||||||
|
const processKey = sessionId ? `${progressKey}:${sessionId}` : progressKey;
|
||||||
|
const activeProcess = transcodeProcesses.get(processKey);
|
||||||
if (!activeProcess?.command) {
|
if (!activeProcess?.command) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -325,9 +371,9 @@ const stopActiveTranscode = (progressKey) => {
|
|||||||
activeProcess.command.kill('SIGKILL');
|
activeProcess.command.kill('SIGKILL');
|
||||||
}
|
}
|
||||||
} catch (killError) {
|
} catch (killError) {
|
||||||
console.warn(`Failed to kill transcode process for ${progressKey}:`, killError);
|
console.warn(`Failed to kill transcode process for ${processKey}:`, killError);
|
||||||
} finally {
|
} finally {
|
||||||
transcodeProcesses.delete(progressKey);
|
transcodeProcesses.delete(processKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -368,7 +414,13 @@ wss.on('connection', (ws) => {
|
|||||||
}
|
}
|
||||||
addWsClient(progressKey, ws);
|
addWsClient(progressKey, ws);
|
||||||
|
|
||||||
const currentProgress = progressMap[progressKey];
|
// Check for current progress, fallback to base key if this is a sub-key and no specific progress exists
|
||||||
|
let currentProgress = progressMap[progressKey];
|
||||||
|
if (!currentProgress && progressKey.includes('-sub')) {
|
||||||
|
const baseKey = progressKey.split('-sub')[0];
|
||||||
|
currentProgress = progressMap[baseKey];
|
||||||
|
}
|
||||||
|
|
||||||
if (currentProgress) {
|
if (currentProgress) {
|
||||||
if (typeof currentProgress.duration === 'number' && currentProgress.duration > 0) {
|
if (typeof currentProgress.duration === 'number' && currentProgress.duration > 0) {
|
||||||
ws.send(JSON.stringify({ type: 'duration', key: message.key, duration: currentProgress.duration }));
|
ws.send(JSON.stringify({ type: 'duration', key: message.key, duration: currentProgress.duration }));
|
||||||
@@ -391,11 +443,19 @@ const ensureS3Downloaded = async (s3Client, bucket, key, tmpInputPath, progressK
|
|||||||
if (activeDownloads.has(progressKey)) {
|
if (activeDownloads.has(progressKey)) {
|
||||||
try {
|
try {
|
||||||
await activeDownloads.get(progressKey);
|
await activeDownloads.get(progressKey);
|
||||||
|
return; // Already downloaded by another request
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore error and retry if previous failed
|
// Ignore error and retry if previous failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downloadingPath = tmpInputPath + '.downloading';
|
||||||
|
// Cleanup stale downloading file if no active download is tracked
|
||||||
|
if (fs.existsSync(downloadingPath)) {
|
||||||
|
fs.rmSync(downloadingPath, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
let shouldDownload = true;
|
let shouldDownload = true;
|
||||||
let s3Metadata = null;
|
let s3Metadata = null;
|
||||||
|
|
||||||
@@ -454,9 +514,14 @@ const ensureS3Downloaded = async (s3Client, bucket, key, tmpInputPath, progressK
|
|||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const writeStream = fs.createWriteStream(downloadingPath);
|
const writeStream = fs.createWriteStream(downloadingPath);
|
||||||
|
let lastBroadcastTime = 0;
|
||||||
|
|
||||||
s3Stream.on('data', (chunk) => {
|
s3Stream.on('data', (chunk) => {
|
||||||
downloadedBytes += chunk.length;
|
downloadedBytes += chunk.length;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Throttle broadcasts to max once every 100ms
|
||||||
|
if (now - lastBroadcastTime > 100 || downloadedBytes === totalBytes) {
|
||||||
const percent = totalBytes ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) : 0;
|
const percent = totalBytes ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) : 0;
|
||||||
const downloadState = {
|
const downloadState = {
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
@@ -464,11 +529,13 @@ const ensureS3Downloaded = async (s3Client, bucket, key, tmpInputPath, progressK
|
|||||||
downloadedBytes,
|
downloadedBytes,
|
||||||
totalBytes,
|
totalBytes,
|
||||||
streamSessionId,
|
streamSessionId,
|
||||||
details: totalBytes ? `Downloading source ${percent}%` : 'Downloading source...',
|
details: totalBytes ? `正在从S3下载源文件 ${percent}%` : '正在从S3下载源文件...',
|
||||||
mp4Url: null
|
mp4Url: null
|
||||||
};
|
};
|
||||||
progressMap[progressKey] = downloadState;
|
progressMap[progressKey] = downloadState;
|
||||||
broadcastWs(progressKey, { type: 'progress', key, progress: downloadState });
|
broadcastWs(progressKey, { type: 'progress', key, progress: downloadState });
|
||||||
|
lastBroadcastTime = now;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
s3Stream.on('error', reject);
|
s3Stream.on('error', reject);
|
||||||
@@ -563,12 +630,12 @@ app.get('/api/videos', async (req, res) => {
|
|||||||
return videoExtensions.some(ext => lowerKey.endsWith(ext));
|
return videoExtensions.some(ext => lowerKey.endsWith(ext));
|
||||||
})
|
})
|
||||||
.map(key => {
|
.map(key => {
|
||||||
const safeBucket = bucket.replace(/[^a-z0-9]/gi, '_');
|
const hlsDir = getHlsCacheDir(bucket, key);
|
||||||
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-z0-9]/gi, '_'));
|
const tmpInputPath = getInputCachePath(bucket, key);
|
||||||
const hlsDir = path.join(CACHE_DIR, `hls-${safeBucket}-${safeKeySegments.join('-')}`);
|
|
||||||
return {
|
return {
|
||||||
key: key,
|
key: key,
|
||||||
hasTranscodeCache: fs.existsSync(hlsDir)
|
hasTranscodeCache: fs.existsSync(hlsDir),
|
||||||
|
hasDownloadCache: fs.existsSync(tmpInputPath)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -625,12 +692,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 safeBucket = bucket.replace(/[^a-z0-9]/gi, '_');
|
|
||||||
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-z0-9]/gi, '_'));
|
|
||||||
const hlsDir = path.join(CACHE_DIR, `hls-${safeBucket}-${safeKeySegments.join('-')}`);
|
|
||||||
|
|
||||||
if (fs.existsSync(hlsDir)) {
|
const safeBucket = makeSafeName(bucket);
|
||||||
fs.rmSync(hlsDir, { recursive: true, force: true });
|
const safeKey = key.split('/').map(makeSafeName).join('-');
|
||||||
|
const cachePrefix = `hls-${safeBucket}-${safeKey}`;
|
||||||
|
|
||||||
|
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' });
|
res.json({ message: 'Transcode cache cleared for video' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -639,6 +713,24 @@ app.post('/api/clear-video-transcode-cache', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/clear-video-download-cache', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { bucket, key } = req.body;
|
||||||
|
if (!bucket || !key) {
|
||||||
|
return res.status(400).json({ error: 'Bucket and key are required' });
|
||||||
|
}
|
||||||
|
const tmpInputPath = getInputCachePath(bucket, key);
|
||||||
|
|
||||||
|
if (fs.existsSync(tmpInputPath)) {
|
||||||
|
fs.rmSync(tmpInputPath, { force: true });
|
||||||
|
}
|
||||||
|
res.json({ message: 'Download cache cleared for video' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing video download cache:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to clear download cache', detail: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/stop-transcode', (req, res) => {
|
app.post('/api/stop-transcode', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { key } = req.body;
|
const { key } = req.body;
|
||||||
@@ -669,17 +761,19 @@ const HLS_SEGMENT_TIME = 6;
|
|||||||
const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => {
|
const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const segPath = path.join(hlsDir, `segment_${segIndex}.ts`);
|
const segPath = path.join(hlsDir, `segment_${segIndex}.ts`);
|
||||||
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
|
|
||||||
|
|
||||||
|
// Check all m3u8 files in the directory to see if any process has finished this segment
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
if (fs.existsSync(m3u8Path)) {
|
if (fs.existsSync(segPath)) {
|
||||||
const m3u8Content = fs.readFileSync(m3u8Path, 'utf8');
|
// Even if .ts exists, ensure it's logged in some m3u8 to confirm it's ready
|
||||||
|
const files = fs.readdirSync(hlsDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.m3u8')) {
|
||||||
|
const m3u8Content = fs.readFileSync(path.join(hlsDir, file), 'utf8');
|
||||||
if (m3u8Content.includes(`segment_${segIndex}.ts`)) {
|
if (m3u8Content.includes(`segment_${segIndex}.ts`)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (m3u8Content.includes(`#EXT-X-ENDLIST`)) {
|
}
|
||||||
if (fs.existsSync(segPath)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await new Promise(r => setTimeout(r, 200));
|
await new Promise(r => setTimeout(r, 200));
|
||||||
@@ -687,14 +781,47 @@ 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 safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
const tmpInputPath = getInputCachePath(bucket, key);
|
||||||
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
|
||||||
const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
|
||||||
|
|
||||||
const auth = await extractS3Credentials(req);
|
const auth = await extractS3Credentials(req);
|
||||||
const s3Client = createS3Client(auth);
|
const s3Client = createS3Client(auth);
|
||||||
@@ -710,24 +837,40 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
|
|||||||
|
|
||||||
let duration = 0;
|
let duration = 0;
|
||||||
try {
|
try {
|
||||||
|
console.log(`[HLS] Probing file: ${key} (${tmpInputPath})`);
|
||||||
const metadata = await probeFile(tmpInputPath);
|
const metadata = await probeFile(tmpInputPath);
|
||||||
duration = parseFloat(metadata.format?.duration || 0);
|
duration = parseFloat(metadata.format?.duration || 0);
|
||||||
} catch (err) { }
|
console.log(`[HLS] Probe complete: duration=${duration}s`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[HLS] Probe failed for ${key}:`, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
if (duration <= 0) duration = 3600;
|
if (duration <= 0) {
|
||||||
|
duration = 3600;
|
||||||
|
console.warn(`[HLS] Duration invalid, using fallback: ${duration}s`);
|
||||||
|
}
|
||||||
|
|
||||||
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}, 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}` : '';
|
||||||
|
const sessionId = req.query.sessionId;
|
||||||
|
const username = req.query.username;
|
||||||
|
const password = req.query.password;
|
||||||
|
const authParam = sessionId ? `&sessionId=${encodeURIComponent(sessionId)}` :
|
||||||
|
(username && password ? `&username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}` : '');
|
||||||
|
|
||||||
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}${authParam}\n`;
|
||||||
}
|
}
|
||||||
m3u8 += `#EXT-X-ENDLIST\n`;
|
m3u8 += `#EXT-X-ENDLIST\n`;
|
||||||
|
|
||||||
|
console.log(`[HLS] Sending m3u8 playlist to client (${m3u8.length} bytes)`);
|
||||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
res.send(m3u8);
|
res.send(m3u8);
|
||||||
@@ -738,7 +881,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');
|
||||||
@@ -752,72 +896,95 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
app.get('/api/hls/segment.ts', async (req, res) => {
|
const startHlsTranscode = async (bucket, key, seg, requestedEncoder, requestedDecoder, subtitleIndex, isPersistent = false, overrideProgressKey = null) => {
|
||||||
const bucket = req.query.bucket;
|
const baseProgressKey = getProgressKey(key);
|
||||||
const key = req.query.key;
|
const subtitleSuffix = (subtitleIndex !== null && subtitleIndex !== undefined && subtitleIndex !== '-1') ? `-sub${subtitleIndex}` : '';
|
||||||
const seg = parseInt(req.query.seg || '0');
|
const progressKey = overrideProgressKey || `${baseProgressKey}${subtitleSuffix}`;
|
||||||
const requestedEncoder = req.query.encoder || 'h264_rkmpp';
|
|
||||||
const requestedDecoder = req.query.decoder || 'auto';
|
|
||||||
|
|
||||||
if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request');
|
const hlsDir = getHlsCacheDir(bucket, key, subtitleIndex);
|
||||||
|
const tmpInputPath = getInputCachePath(bucket, key);
|
||||||
|
|
||||||
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
|
||||||
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
|
||||||
const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
|
||||||
|
|
||||||
const progressKey = safeKeySegments.join('/');
|
|
||||||
const hlsDir = path.join(CACHE_DIR, `hls-${safeBucket}-${progressKey}`);
|
|
||||||
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`);
|
|
||||||
let currentProcess = hlsProcesses.get(progressKey);
|
let currentProcess = hlsProcesses.get(progressKey);
|
||||||
|
|
||||||
if (currentProcess) {
|
|
||||||
currentProcess.lastActive = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkIsCachedAndCompleted = () => {
|
|
||||||
if (!fs.existsSync(targetSegPath)) return false;
|
|
||||||
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
|
|
||||||
if (fs.existsSync(m3u8Path) && fs.readFileSync(m3u8Path, 'utf8').includes(`segment_${seg}.ts`)) return true;
|
|
||||||
if (currentProcess && Math.abs((currentProcess.currentSeg || 0) - seg) > 3) return true;
|
|
||||||
// If there's no active process, any existing file is from a past complete run
|
|
||||||
if (!currentProcess) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (checkIsCachedAndCompleted()) {
|
|
||||||
if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg);
|
|
||||||
res.setHeader('Content-Type', 'video/MP2T');
|
|
||||||
return res.sendFile(targetSegPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const needsNewProcess = !currentProcess || (!fs.existsSync(targetSegPath) && (seg < (currentProcess.currentSeg || 0) || seg > (currentProcess.currentSeg || 0) + 4));
|
|
||||||
|
|
||||||
if (needsNewProcess) {
|
|
||||||
if (currentProcess && currentProcess.command) {
|
if (currentProcess && currentProcess.command) {
|
||||||
|
// If the current process is persistent and we are starting a non-persistent one,
|
||||||
|
// don't kill it. We'll spawn a side-process instead.
|
||||||
|
if (currentProcess.persistent && !isPersistent) {
|
||||||
|
console.log(`[HLS] Persistent process running for ${progressKey}, spawning side-process for seek.`);
|
||||||
|
// Change progressKey for the side-process to avoid killing the persistent one
|
||||||
|
// Use a session-specific or random suffix
|
||||||
|
const sideKey = `${progressKey}:side:${Date.now()}`;
|
||||||
|
const sideProcess = await startHlsTranscode(bucket, key, seg, requestedEncoder, requestedDecoder, subtitleIndex, false, sideKey);
|
||||||
|
return sideProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[HLS] Killing previous FFmpeg process for ${progressKey}`);
|
||||||
try { currentProcess.command.kill('SIGKILL'); } catch (e) { }
|
try { currentProcess.command.kill('SIGKILL'); } catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const startTime = Math.max(0, seg * HLS_SEGMENT_TIME);
|
const startTime = Math.max(0, seg * HLS_SEGMENT_TIME);
|
||||||
|
|
||||||
let sourceMetadata = null;
|
let sourceMetadata = null;
|
||||||
try { sourceMetadata = await probeFile(tmpInputPath); } catch (e) { }
|
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 encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
|
||||||
const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
|
const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
|
||||||
|
|
||||||
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
|
console.log(`[HLS] FFmpeg config: encoder=${encoderName}, decoder=${decoderName}, startTime=${startTime}s, subtitleIndex=${subtitleIndex}, persistent=${isPersistent}`);
|
||||||
if (fs.existsSync(m3u8Path)) fs.unlinkSync(m3u8Path);
|
|
||||||
|
const m3u8Name = isPersistent ? 'temp.m3u8' : `temp_${progressKey.replace(/[^a-zA-Z0-9]/g, '_')}.m3u8`;
|
||||||
|
const m3u8Path = path.join(hlsDir, m3u8Name).replace(/\\/g, '/');
|
||||||
|
if (fs.existsSync(m3u8Path)) try { fs.unlinkSync(m3u8Path); } catch (e) {}
|
||||||
|
|
||||||
|
const normalizedInputPath = tmpInputPath.replace(/\\/g, '/');
|
||||||
|
const ffmpegCommand = ffmpeg().input(normalizedInputPath);
|
||||||
|
|
||||||
const ffmpegCommand = ffmpeg().input(tmpInputPath);
|
|
||||||
if (startTime > 0) ffmpegCommand.seekInput(startTime);
|
if (startTime > 0) ffmpegCommand.seekInput(startTime);
|
||||||
|
|
||||||
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})`);
|
||||||
|
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;
|
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]);
|
||||||
|
|
||||||
@@ -836,9 +1003,28 @@ app.get('/api/hls/segment.ts', async (req, res) => {
|
|||||||
|
|
||||||
ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path);
|
ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path);
|
||||||
ffmpegCommand.on('error', (err) => {
|
ffmpegCommand.on('error', (err) => {
|
||||||
console.error('HLS FFmpeg Error:', err.message);
|
console.error(`[HLS] FFmpeg Error for ${progressKey}:`, err.message);
|
||||||
|
const broadcastRoomId = `${baseProgressKey}${subtitleSuffix}`;
|
||||||
|
const broadcastKey = subtitleIndex && subtitleIndex !== '-1' ? `${key}-sub${subtitleIndex}` : key;
|
||||||
|
const failedState = {
|
||||||
|
status: 'failed',
|
||||||
|
percent: 0,
|
||||||
|
details: `FFmpeg Error: ${err.message}`,
|
||||||
|
mp4Url: null
|
||||||
|
};
|
||||||
|
progressMap[broadcastRoomId] = failedState;
|
||||||
|
broadcastWs(broadcastRoomId, { type: 'progress', key: broadcastKey, progress: failedState });
|
||||||
|
hlsProcesses.delete(progressKey);
|
||||||
|
// Clean up temporary m3u8 file
|
||||||
|
const m3u8Name = isPersistent ? 'temp.m3u8' : `temp_${progressKey.replace(/[^a-zA-Z0-9]/g, '_')}.m3u8`;
|
||||||
|
const m3u8Path = path.join(hlsDir, m3u8Name);
|
||||||
|
if (!isPersistent && fs.existsSync(m3u8Path)) {
|
||||||
|
try { fs.unlinkSync(m3u8Path); } catch (e) {}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ffmpegCommand.on('progress', (progress) => {
|
ffmpegCommand.on('progress', (progress) => {
|
||||||
const timemarkSeconds = parseTimemarkToSeconds(progress.timemark || '0');
|
const timemarkSeconds = parseTimemarkToSeconds(progress.timemark || '0');
|
||||||
const absoluteSeconds = startTime + (isFinite(timemarkSeconds) ? timemarkSeconds : 0);
|
const absoluteSeconds = startTime + (isFinite(timemarkSeconds) ? timemarkSeconds : 0);
|
||||||
@@ -852,33 +1038,180 @@ app.get('/api/hls/segment.ts', async (req, res) => {
|
|||||||
const progressState = {
|
const progressState = {
|
||||||
status: 'transcoding',
|
status: 'transcoding',
|
||||||
percent,
|
percent,
|
||||||
frame: progress.frames || null,
|
frame: sanitizeNumber(progress.frames),
|
||||||
fps: progress.currentFps || null,
|
fps: sanitizeNumber(progress.currentFps),
|
||||||
bitrate: progress.currentKbps || null,
|
bitrate: sanitizeNumber(progress.currentKbps),
|
||||||
timemark: progress.timemark || null,
|
timemark: progress.timemark || null,
|
||||||
absoluteSeconds,
|
absoluteSeconds,
|
||||||
duration: totalDuration || null,
|
duration: totalDuration || null,
|
||||||
startSeconds: startTime,
|
startSeconds: startTime,
|
||||||
details: `处理进度 ${percent}%`,
|
details: isPersistent ? `后台预切片中 ${percent}%` : `处理进度 ${percent}%`,
|
||||||
mp4Url: null
|
mp4Url: null
|
||||||
};
|
};
|
||||||
progressMap[progressKey] = progressState;
|
const broadcastRoomId = `${baseProgressKey}${subtitleSuffix}`;
|
||||||
broadcastWs(progressKey, { type: 'progress', key, progress: progressState });
|
const broadcastKey = subtitleIndex && subtitleIndex !== '-1' ? `${key}-sub${subtitleIndex}` : key;
|
||||||
|
|
||||||
console.log(`[FFmpeg] ${progressKey} | ${progress.timemark} | ${progress.currentFps}fps | ${progress.currentKbps}kbps | ${percent}%`);
|
progressMap[broadcastRoomId] = progressState;
|
||||||
|
broadcastWs(broadcastRoomId, { 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', () => {
|
ffmpegCommand.on('end', () => {
|
||||||
console.log(`[FFmpeg] ${progressKey} HLS transcode completed.`);
|
console.log(`[FFmpeg] ${progressKey} HLS transcode completed.`);
|
||||||
|
const broadcastRoomId = `${baseProgressKey}${subtitleSuffix}`;
|
||||||
|
const broadcastKey = subtitleIndex && subtitleIndex !== '-1' ? `${key}-sub${subtitleIndex}` : key;
|
||||||
|
|
||||||
|
const finishedState = {
|
||||||
|
status: 'finished',
|
||||||
|
percent: 100,
|
||||||
|
details: isPersistent ? '预切片完成' : '处理完成'
|
||||||
|
};
|
||||||
|
progressMap[broadcastRoomId] = finishedState;
|
||||||
|
|
||||||
|
broadcastWs(broadcastRoomId, {
|
||||||
|
type: 'progress',
|
||||||
|
key: broadcastKey,
|
||||||
|
progress: finishedState
|
||||||
|
});
|
||||||
|
hlsProcesses.delete(progressKey);
|
||||||
|
// Clean up temporary m3u8 file
|
||||||
|
if (!isPersistent && fs.existsSync(m3u8Path)) {
|
||||||
|
try { fs.unlinkSync(m3u8Path); } catch (e) {}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
ffmpegCommand.run();
|
ffmpegCommand.run();
|
||||||
currentProcess = { command: ffmpegCommand, currentSeg: seg, lastActive: Date.now() };
|
console.log(`[HLS] FFmpeg process started for ${progressKey} (persistent=${isPersistent}, m3u8=${m3u8Name})`);
|
||||||
hlsProcesses.set(progressKey, currentProcess);
|
const newProcessInfo = { command: ffmpegCommand, currentSeg: seg, lastActive: Date.now(), persistent: isPersistent, m3u8Name };
|
||||||
|
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) => {
|
||||||
|
const bucket = req.query.bucket;
|
||||||
|
const key = req.query.key;
|
||||||
|
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}, sub=${subtitleIndex}`);
|
||||||
|
|
||||||
|
const tmpInputPath = getInputCachePath(bucket, key);
|
||||||
|
|
||||||
|
// Ensure the video is fully downloaded before attempting to transcode segments
|
||||||
|
const auth = await extractS3Credentials(req);
|
||||||
|
const s3Client = createS3Client(auth);
|
||||||
|
const baseProgressKeyForDownload = getProgressKey(key);
|
||||||
|
const streamSessionId = createStreamSessionId();
|
||||||
|
try {
|
||||||
|
await ensureS3Downloaded(s3Client, bucket, key, tmpInputPath, baseProgressKeyForDownload, streamSessionId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('S3 Download Failed for segment:', err);
|
||||||
|
return res.status(500).send('S3 Download Failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseProgressKey = getProgressKey(key);
|
||||||
|
const subtitleSuffix = (subtitleIndex !== null && subtitleIndex !== undefined && subtitleIndex !== '-1') ? `-sub${subtitleIndex}` : '';
|
||||||
|
const progressKeyPrefix = `${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`);
|
||||||
|
|
||||||
|
// Find a process that is "closest" to our segment to avoid redundant side-processes
|
||||||
|
let currentProcess = null;
|
||||||
|
let closestKey = null;
|
||||||
|
for (const [procKey, procInfo] of hlsProcesses.entries()) {
|
||||||
|
if (procKey === progressKeyPrefix || procKey.startsWith(`${progressKeyPrefix}:`)) {
|
||||||
|
// Re-use if it's within a window of 2 to 10 segments ahead
|
||||||
|
if (seg >= (procInfo.currentSeg || 0) && seg <= (procInfo.currentSeg || 0) + 12) {
|
||||||
|
currentProcess = procInfo;
|
||||||
|
closestKey = procKey;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentProcess) {
|
||||||
|
currentProcess.lastActive = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const checkIsCachedAndCompleted = () => {
|
||||||
|
if (!fs.existsSync(targetSegPath)) return false;
|
||||||
|
|
||||||
|
// Check all m3u8 files in the directory to see if any process has finished this segment
|
||||||
|
const files = fs.readdirSync(hlsDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.m3u8')) {
|
||||||
|
const m3u8Content = fs.readFileSync(path.join(hlsDir, file), 'utf8');
|
||||||
|
if (m3u8Content.includes(`segment_${seg}.ts`)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentProcess && Math.abs((currentProcess.currentSeg || 0) - seg) > 3) return true;
|
||||||
|
// If there's no active process, any existing file is from a past complete run or already verified
|
||||||
|
if (!currentProcess) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (checkIsCachedAndCompleted()) {
|
||||||
|
if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg);
|
||||||
|
res.setHeader('Content-Type', 'video/MP2T');
|
||||||
|
return res.sendFile(targetSegPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have no process or the closest one is too far, start a new one (only if we don't already have the segment)
|
||||||
|
const needsNewProcess = !currentProcess && !checkIsCachedAndCompleted();
|
||||||
|
|
||||||
|
if (needsNewProcess) {
|
||||||
|
console.log(`[HLS] Starting new FFmpeg side-process for seg=${seg}, key=${key}`);
|
||||||
|
currentProcess = await startHlsTranscode(bucket, key, seg, requestedEncoder, requestedDecoder, subtitleIndex);
|
||||||
|
} else if (currentProcess) {
|
||||||
|
console.log(`[HLS] Reusing existing FFmpeg process (${closestKey}) for seg=${seg}, currently at ${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}`);
|
||||||
return res.status(500).send('Segment generation timeout');
|
return res.status(500).send('Segment generation timeout');
|
||||||
}
|
}
|
||||||
if (currentProcess) {
|
if (currentProcess) {
|
||||||
@@ -886,6 +1219,7 @@ app.get('/api/hls/segment.ts', async (req, res) => {
|
|||||||
currentProcess.lastActive = Date.now();
|
currentProcess.lastActive = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[HLS] Serving segment: seg=${seg}`);
|
||||||
res.setHeader('Content-Type', 'video/MP2T');
|
res.setHeader('Content-Type', 'video/MP2T');
|
||||||
res.sendFile(targetSegPath);
|
res.sendFile(targetSegPath);
|
||||||
});
|
});
|
||||||
@@ -912,15 +1246,15 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
|
|
||||||
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
||||||
const progressKey = safeKeySegments.join('/');
|
const progressKey = safeKeySegments.join('/');
|
||||||
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
const tmpInputPath = getInputCachePath(bucket, key);
|
||||||
const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
|
||||||
const cacheExists = fs.existsSync(tmpInputPath);
|
const cacheExists = fs.existsSync(tmpInputPath);
|
||||||
|
|
||||||
const auth = await extractS3Credentials(req);
|
const auth = await extractS3Credentials(req);
|
||||||
const s3Client = createS3Client(auth);
|
const s3Client = createS3Client(auth);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const replacedExistingStream = stopActiveTranscode(progressKey);
|
const replacedExistingStream = stopActiveTranscode(progressKey, streamSessionId);
|
||||||
|
|
||||||
if (replacedExistingStream && startSeconds > 0) {
|
if (replacedExistingStream && startSeconds > 0) {
|
||||||
progressMap[progressKey] = {
|
progressMap[progressKey] = {
|
||||||
...(progressMap[progressKey] || {}),
|
...(progressMap[progressKey] || {}),
|
||||||
@@ -989,7 +1323,9 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]);
|
ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]);
|
||||||
}
|
}
|
||||||
|
|
||||||
transcodeProcesses.set(progressKey, { command: ffmpegCommand, streamSessionId });
|
const processKey = `${progressKey}:${streamSessionId}`;
|
||||||
|
transcodeProcesses.set(processKey, { command: ffmpegCommand, streamSessionId });
|
||||||
|
|
||||||
|
|
||||||
ffmpegCommand
|
ffmpegCommand
|
||||||
.on('progress', (progress) => {
|
.on('progress', (progress) => {
|
||||||
@@ -1005,9 +1341,9 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
const progressState = {
|
const progressState = {
|
||||||
status: 'transcoding',
|
status: 'transcoding',
|
||||||
percent,
|
percent,
|
||||||
frame: progress.frames || null,
|
frame: sanitizeNumber(progress.frames),
|
||||||
fps: progress.currentFps || null,
|
fps: sanitizeNumber(progress.currentFps),
|
||||||
bitrate: progress.currentKbps || null,
|
bitrate: sanitizeNumber(progress.currentKbps),
|
||||||
timemark: progress.timemark || null,
|
timemark: progress.timemark || null,
|
||||||
absoluteSeconds,
|
absoluteSeconds,
|
||||||
duration: progressMap[progressKey]?.duration || null,
|
duration: progressMap[progressKey]?.duration || null,
|
||||||
@@ -1073,9 +1409,11 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
ffmpegCommand.kill('SIGKILL');
|
ffmpegCommand.kill('SIGKILL');
|
||||||
} catch (_) { }
|
} catch (_) { }
|
||||||
}
|
}
|
||||||
if (transcodeProcesses.get(progressKey)?.streamSessionId === streamSessionId) {
|
const processKey = `${progressKey}:${streamSessionId}`;
|
||||||
transcodeProcesses.delete(progressKey);
|
if (transcodeProcesses.get(processKey)?.streamSessionId === streamSessionId) {
|
||||||
|
transcodeProcesses.delete(processKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
startStream(videoEncoder, requestedVideoDecoder);
|
startStream(videoEncoder, requestedVideoDecoder);
|
||||||
|
|||||||
Reference in New Issue
Block a user