添加内嵌字幕识别
This commit is contained in:
@@ -53,10 +53,6 @@
|
|||||||
<option value="" disabled selected>Choose a bucket</option>
|
<option value="" disabled selected>Choose a bucket</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="codec-panel">
|
|
||||||
<label for="decoder-select">视频解码器:</label>
|
|
||||||
<select id="decoder-select"></select>
|
|
||||||
</div>
|
|
||||||
<div class="codec-panel">
|
<div class="codec-panel">
|
||||||
<label for="encoder-select">硬件编码器:</label>
|
<label for="encoder-select">硬件编码器:</label>
|
||||||
<select id="encoder-select"></select>
|
<select id="encoder-select"></select>
|
||||||
@@ -143,6 +139,17 @@
|
|||||||
<span id="seek-total-time" class="control-time">00:00:00</span>
|
<span id="seek-total-time" class="control-time">00:00:00</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls-right">
|
<div class="controls-right">
|
||||||
|
<div id="subtitle-control" class="control-popover speed-popover hidden">
|
||||||
|
<button id="control-subtitle-toggle" class="control-btn" type="button" aria-label="Subtitles">CC Off</button>
|
||||||
|
<div class="popover-panel">
|
||||||
|
<label class="speed-control" for="subtitle-select">
|
||||||
|
<span>Subtitles</span>
|
||||||
|
<select id="subtitle-select" class="speed-select">
|
||||||
|
<option value="off" selected>Off</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="control-popover speed-popover">
|
<div class="control-popover speed-popover">
|
||||||
<button id="control-speed-toggle" class="control-btn" type="button" aria-label="Playback Speed">1x</button>
|
<button id="control-speed-toggle" class="control-btn" type="button" aria-label="Playback Speed">1x</button>
|
||||||
<div class="popover-panel">
|
<div class="popover-panel">
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const loginPasswordInput = document.getElementById('login-password');
|
const loginPasswordInput = document.getElementById('login-password');
|
||||||
const loginBtn = document.getElementById('login-btn');
|
const loginBtn = document.getElementById('login-btn');
|
||||||
const loginError = document.getElementById('login-error');
|
const loginError = document.getElementById('login-error');
|
||||||
const decoderSelect = document.getElementById('decoder-select');
|
|
||||||
const encoderSelect = document.getElementById('encoder-select');
|
const encoderSelect = document.getElementById('encoder-select');
|
||||||
const decoderLabel = document.querySelector('label[for="decoder-select"]');
|
|
||||||
const encoderLabel = document.querySelector('label[for="encoder-select"]');
|
const encoderLabel = document.querySelector('label[for="encoder-select"]');
|
||||||
const playerOverlay = document.getElementById('player-overlay');
|
const playerOverlay = document.getElementById('player-overlay');
|
||||||
const transcodingOverlay = document.getElementById('transcoding-overlay');
|
const transcodingOverlay = document.getElementById('transcoding-overlay');
|
||||||
@@ -28,7 +26,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const controlPlayToggle = document.getElementById('control-play-toggle');
|
const controlPlayToggle = document.getElementById('control-play-toggle');
|
||||||
const controlMuteToggle = document.getElementById('control-mute-toggle');
|
const controlMuteToggle = document.getElementById('control-mute-toggle');
|
||||||
const controlFullscreenToggle = document.getElementById('control-fullscreen-toggle');
|
const controlFullscreenToggle = document.getElementById('control-fullscreen-toggle');
|
||||||
|
const controlSubtitleToggle = document.getElementById('control-subtitle-toggle');
|
||||||
const controlSpeedToggle = document.getElementById('control-speed-toggle');
|
const controlSpeedToggle = document.getElementById('control-speed-toggle');
|
||||||
|
const subtitleControl = document.getElementById('subtitle-control');
|
||||||
|
const subtitleSelect = document.getElementById('subtitle-select');
|
||||||
const volumeSlider = document.getElementById('volume-slider');
|
const volumeSlider = document.getElementById('volume-slider');
|
||||||
const volumeValue = document.getElementById('volume-value');
|
const volumeValue = document.getElementById('volume-value');
|
||||||
const playbackStatus = document.getElementById('playback-status');
|
const playbackStatus = document.getElementById('playback-status');
|
||||||
@@ -84,6 +85,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
let controlsHideTimeout = null;
|
let controlsHideTimeout = null;
|
||||||
let controlsHovered = false;
|
let controlsHovered = false;
|
||||||
let controlsPointerReleaseTimeout = null;
|
let controlsPointerReleaseTimeout = null;
|
||||||
|
let managedSubtitleTracks = [];
|
||||||
|
let selectedSubtitleTrackId = 'off';
|
||||||
|
|
||||||
if (videoPlayer) {
|
if (videoPlayer) {
|
||||||
videoPlayer.controls = false;
|
videoPlayer.controls = false;
|
||||||
@@ -91,9 +94,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (playbackSpeed) {
|
if (playbackSpeed) {
|
||||||
videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1;
|
videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1;
|
||||||
}
|
}
|
||||||
if (decoderLabel) {
|
|
||||||
decoderLabel.textContent = '视频解码器:';
|
|
||||||
}
|
|
||||||
if (encoderLabel) {
|
if (encoderLabel) {
|
||||||
encoderLabel.textContent = '视频编码器:';
|
encoderLabel.textContent = '视频编码器:';
|
||||||
}
|
}
|
||||||
@@ -142,10 +142,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildStreamUrl = (targetSeconds = null) => {
|
const buildStreamUrl = (targetSeconds = null) => {
|
||||||
const decoder = decoderSelect?.value || 'auto';
|
|
||||||
const encoder = encoderSelect?.value || 'h264_rkmpp';
|
const encoder = encoderSelect?.value || 'h264_rkmpp';
|
||||||
const streamSessionId = createStreamSessionId();
|
const streamSessionId = createStreamSessionId();
|
||||||
let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`;
|
let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=auto&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`;
|
||||||
if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) {
|
if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) {
|
||||||
streamUrl += `&ss=${targetSeconds}`;
|
streamUrl += `&ss=${targetSeconds}`;
|
||||||
}
|
}
|
||||||
@@ -204,6 +203,119 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateSubtitleControls = () => {
|
||||||
|
if (!controlSubtitleToggle || !subtitleSelect) return;
|
||||||
|
const selectedOption = subtitleSelect.selectedOptions?.[0];
|
||||||
|
controlSubtitleToggle.textContent = selectedOption ? selectedOption.textContent : 'CC Off';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSubtitleTracks = () => {
|
||||||
|
managedSubtitleTracks.forEach((track) => track.element.remove());
|
||||||
|
managedSubtitleTracks = [];
|
||||||
|
selectedSubtitleTrackId = 'off';
|
||||||
|
|
||||||
|
if (subtitleSelect) {
|
||||||
|
subtitleSelect.innerHTML = '';
|
||||||
|
const offOption = document.createElement('option');
|
||||||
|
offOption.value = 'off';
|
||||||
|
offOption.textContent = 'Off';
|
||||||
|
subtitleSelect.appendChild(offOption);
|
||||||
|
subtitleSelect.value = 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitleControl) {
|
||||||
|
subtitleControl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSubtitleControls();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySelectedSubtitleTrack = () => {
|
||||||
|
managedSubtitleTracks.forEach((track) => {
|
||||||
|
if (track.element.track) {
|
||||||
|
track.element.track.mode = track.id === selectedSubtitleTrackId ? 'showing' : 'disabled';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateSubtitleControls();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSubtitleUrl = (streamIndex, targetSeconds = 0) => {
|
||||||
|
let subtitleUrl = `/api/subtitles?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&streamIndex=${encodeURIComponent(streamIndex)}`;
|
||||||
|
if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) {
|
||||||
|
subtitleUrl += `&ss=${targetSeconds}`;
|
||||||
|
}
|
||||||
|
if (s3Username) subtitleUrl += `&username=${encodeURIComponent(s3Username)}`;
|
||||||
|
if (s3Password) subtitleUrl += `&password=${encodeURIComponent(s3Password)}`;
|
||||||
|
return subtitleUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSubtitleTracks = async (targetSeconds = 0) => {
|
||||||
|
if (!selectedBucket || !selectedKey || !subtitleSelect) {
|
||||||
|
resetSubtitleTracks();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousSelection = selectedSubtitleTrackId;
|
||||||
|
resetSubtitleTracks();
|
||||||
|
|
||||||
|
try {
|
||||||
|
let subtitleListUrl = `/api/subtitles?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}`;
|
||||||
|
if (s3Username) subtitleListUrl += `&username=${encodeURIComponent(s3Username)}`;
|
||||||
|
if (s3Password) subtitleListUrl += `&password=${encodeURIComponent(s3Password)}`;
|
||||||
|
|
||||||
|
const res = await fetch(subtitleListUrl);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to load subtitles');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const tracks = Array.isArray(data.tracks) ? data.tracks : [];
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks.forEach((track) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = String(track.index);
|
||||||
|
option.textContent = track.label || `Subtitle ${track.index}`;
|
||||||
|
option.disabled = !track.supported;
|
||||||
|
subtitleSelect.appendChild(option);
|
||||||
|
|
||||||
|
if (!track.supported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackElement = document.createElement('track');
|
||||||
|
trackElement.kind = 'subtitles';
|
||||||
|
trackElement.label = track.label || `Subtitle ${track.index}`;
|
||||||
|
if (track.language) {
|
||||||
|
trackElement.srclang = track.language;
|
||||||
|
}
|
||||||
|
trackElement.src = buildSubtitleUrl(track.index, targetSeconds);
|
||||||
|
trackElement.dataset.managedSubtitle = 'true';
|
||||||
|
videoPlayer.appendChild(trackElement);
|
||||||
|
managedSubtitleTracks.push({
|
||||||
|
id: String(track.index),
|
||||||
|
element: trackElement
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subtitleControl) {
|
||||||
|
subtitleControl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSelection = managedSubtitleTracks.some((track) => track.id === previousSelection)
|
||||||
|
? previousSelection
|
||||||
|
: 'off';
|
||||||
|
subtitleSelect.value = nextSelection;
|
||||||
|
selectedSubtitleTrackId = nextSelection;
|
||||||
|
applySelectedSubtitleTrack();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Subtitle load failed:', error);
|
||||||
|
resetSubtitleTracks();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateTranscodeProgressBar = (percent = 0) => {
|
const updateTranscodeProgressBar = (percent = 0) => {
|
||||||
if (!seekBarTranscode) return;
|
if (!seekBarTranscode) return;
|
||||||
const safePercent = Math.min(Math.max(Math.round(percent || 0), 0), 100);
|
const safePercent = Math.min(Math.max(Math.round(percent || 0), 0), 100);
|
||||||
@@ -600,6 +712,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
isStreamActive = true;
|
isStreamActive = true;
|
||||||
pendingSeekTimeout = null;
|
pendingSeekTimeout = null;
|
||||||
}, 8000);
|
}, 8000);
|
||||||
|
|
||||||
|
loadSubtitleTracks(targetSeconds).catch((error) => {
|
||||||
|
console.error('Subtitle reload failed after seek:', error);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- End custom seek bar ---
|
// --- End custom seek bar ---
|
||||||
@@ -614,7 +730,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
topBanner.textContent = title;
|
topBanner.textContent = title;
|
||||||
topBanner.classList.remove('hidden');
|
topBanner.classList.remove('hidden');
|
||||||
document.title = title;
|
document.title = title;
|
||||||
populateSelect(decoderSelect, data.videoDecoders || [], data.defaultVideoDecoder || 'auto');
|
|
||||||
populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp');
|
populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Config load failed:', err);
|
console.error('Config load failed:', err);
|
||||||
@@ -909,6 +1024,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
playBtn.classList.add('hidden');
|
playBtn.classList.add('hidden');
|
||||||
}
|
}
|
||||||
resetPhases();
|
resetPhases();
|
||||||
|
resetSubtitleTracks();
|
||||||
|
|
||||||
selectedKey = key;
|
selectedKey = key;
|
||||||
currentVideoKey = key;
|
currentVideoKey = key;
|
||||||
@@ -956,6 +1072,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
updateVolumeControls();
|
updateVolumeControls();
|
||||||
updateFullscreenControls();
|
updateFullscreenControls();
|
||||||
schedulePlaybackChromeHide();
|
schedulePlaybackChromeHide();
|
||||||
|
loadSubtitleTracks(seekOffset).catch((error) => {
|
||||||
|
console.error('Subtitle load failed:', error);
|
||||||
|
});
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -984,6 +1103,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
videoPlayer.load();
|
videoPlayer.load();
|
||||||
hideSeekBar();
|
hideSeekBar();
|
||||||
hideCustomControls();
|
hideCustomControls();
|
||||||
|
resetSubtitleTracks();
|
||||||
setPlaybackStatus('Paused', 'paused');
|
setPlaybackStatus('Paused', 'paused');
|
||||||
updatePlayControls();
|
updatePlayControls();
|
||||||
if (transcodeBtn) {
|
if (transcodeBtn) {
|
||||||
@@ -1032,6 +1152,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
updateVolumeControls();
|
updateVolumeControls();
|
||||||
updateFullscreenControls();
|
updateFullscreenControls();
|
||||||
schedulePlaybackChromeHide();
|
schedulePlaybackChromeHide();
|
||||||
|
loadSubtitleTracks(seekOffset).catch((error) => {
|
||||||
|
console.error('Subtitle load failed:', error);
|
||||||
|
});
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1082,6 +1205,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subtitleSelect) {
|
||||||
|
subtitleSelect.addEventListener('change', (event) => {
|
||||||
|
selectedSubtitleTrackId = event.target.value || 'off';
|
||||||
|
applySelectedSubtitleTrack();
|
||||||
|
revealPlaybackChrome();
|
||||||
|
schedulePlaybackChromeHide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (controlFullscreenToggle) {
|
if (controlFullscreenToggle) {
|
||||||
controlFullscreenToggle.addEventListener('click', async () => {
|
controlFullscreenToggle.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1133,6 +1265,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
updateVolumeControls();
|
updateVolumeControls();
|
||||||
updateFullscreenControls();
|
updateFullscreenControls();
|
||||||
updateSpeedControls();
|
updateSpeedControls();
|
||||||
|
resetSubtitleTracks();
|
||||||
updateTranscodeProgressBar(0);
|
updateTranscodeProgressBar(0);
|
||||||
|
|
||||||
// Bind events
|
// Bind events
|
||||||
|
|||||||
207
server.js
207
server.js
@@ -72,36 +72,33 @@ const transcodeProcesses = new Map();
|
|||||||
const wsSubscriptions = new Map();
|
const wsSubscriptions = new Map();
|
||||||
|
|
||||||
const AVAILABLE_VIDEO_ENCODERS = [
|
const AVAILABLE_VIDEO_ENCODERS = [
|
||||||
{ value: 'libx264', label: 'libx264 (Software H.264)' },
|
|
||||||
{ value: 'libx265', label: 'libx265 (Software H.265)' },
|
|
||||||
{ value: 'h264_nvenc', label: 'h264_nvenc (NVIDIA H.264)' },
|
|
||||||
{ value: 'hevc_nvenc', label: 'hevc_nvenc (NVIDIA HEVC)' },
|
|
||||||
{ value: 'h264_qsv', label: 'h264_qsv (Intel QSV H.264)' },
|
|
||||||
{ value: 'hevc_qsv', label: 'hevc_qsv (Intel QSV HEVC)' },
|
|
||||||
{ value: 'h264_vaapi', label: 'h264_vaapi (VAAPI H.264)' },
|
|
||||||
{ value: 'hevc_vaapi', label: 'hevc_vaapi (VAAPI HEVC)' },
|
|
||||||
{ value: 'h264_rkmpp', label: 'h264_rkmpp (RKMPP H.264)' },
|
|
||||||
{ value: 'hevc_rkmpp', label: 'hevc_rkmpp (RKMPP HEVC)' },
|
|
||||||
{ value: 'mjpeg_rkmpp', label: 'mjpeg_rkmpp (RKMPP MJPEG)' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const AVAILABLE_VIDEO_DECODERS = [
|
|
||||||
{ value: 'auto', label: 'Auto Select Decoder' },
|
|
||||||
{ value: 'av1_rkmpp', label: 'av1_rkmpp (RKMPP AV1)' },
|
|
||||||
{ value: 'h263_rkmpp', label: 'h263_rkmpp (RKMPP H.263)' },
|
|
||||||
{ value: 'h264_rkmpp', label: 'h264_rkmpp (RKMPP H.264)' },
|
{ value: 'h264_rkmpp', label: 'h264_rkmpp (RKMPP H.264)' },
|
||||||
{ value: 'hevc_rkmpp', label: 'hevc_rkmpp (RKMPP HEVC)' },
|
{ value: 'hevc_rkmpp', label: 'hevc_rkmpp (RKMPP HEVC)' },
|
||||||
{ value: 'mjpeg_rkmpp', label: 'mjpeg_rkmpp (RKMPP MJPEG)' },
|
{ value: 'mjpeg_rkmpp', label: 'mjpeg_rkmpp (RKMPP MJPEG)' },
|
||||||
{ value: 'mpeg1_rkmpp', label: 'mpeg1_rkmpp (RKMPP MPEG-1)' },
|
{ value: 'libx264', label: 'libx264 (Software / NEON H.264)' },
|
||||||
{ value: 'mpeg2_rkmpp', label: 'mpeg2_rkmpp (RKMPP MPEG-2)' },
|
{ value: 'libx265', label: 'libx265 (Software / NEON H.265)' }
|
||||||
{ value: 'mpeg4_rkmpp', label: 'mpeg4_rkmpp (RKMPP MPEG-4)' },
|
|
||||||
{ value: 'vp8_rkmpp', label: 'vp8_rkmpp (RKMPP VP8)' },
|
|
||||||
{ value: 'vp9_rkmpp', label: 'vp9_rkmpp (RKMPP VP9)' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const AVAILABLE_VIDEO_DECODERS = [
|
||||||
|
{ value: 'auto', label: 'Auto Select Decoder' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SUPPORTED_TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'text', 'webvtt']);
|
||||||
|
|
||||||
const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/');
|
const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/');
|
||||||
|
|
||||||
const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const getCachePathParts = (bucket, key) => {
|
||||||
|
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
||||||
|
const progressKey = safeKeySegments.join('/');
|
||||||
|
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
||||||
|
const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
||||||
|
return {
|
||||||
|
progressKey,
|
||||||
|
safeKeySegments,
|
||||||
|
tmpInputPath
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const addWsClient = (progressKey, ws) => {
|
const addWsClient = (progressKey, ws) => {
|
||||||
if (!wsSubscriptions.has(progressKey)) {
|
if (!wsSubscriptions.has(progressKey)) {
|
||||||
@@ -220,6 +217,73 @@ const shouldRetryWithSoftware = (message) => {
|
|||||||
return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument|mpp_create|rkmpp/i.test(message);
|
return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument|mpp_create|rkmpp/i.test(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureSourceCached = async ({ s3Client, bucket, key, tmpInputPath, onProgress }) => {
|
||||||
|
if (fs.existsSync(tmpInputPath)) {
|
||||||
|
const stats = fs.statSync(tmpInputPath);
|
||||||
|
return {
|
||||||
|
totalBytes: stats.size,
|
||||||
|
downloadedBytes: stats.size,
|
||||||
|
cacheExists: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
||||||
|
const response = await s3Client.send(command);
|
||||||
|
const s3Stream = response.Body;
|
||||||
|
const totalBytes = response.ContentLength || 0;
|
||||||
|
let downloadedBytes = 0;
|
||||||
|
|
||||||
|
if (typeof onProgress === 'function') {
|
||||||
|
onProgress({ totalBytes, downloadedBytes, cacheExists: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const writeStream = fs.createWriteStream(tmpInputPath);
|
||||||
|
s3Stream.on('data', (chunk) => {
|
||||||
|
downloadedBytes += chunk.length;
|
||||||
|
if (typeof onProgress === 'function') {
|
||||||
|
onProgress({ totalBytes, downloadedBytes, cacheExists: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
s3Stream.on('error', reject);
|
||||||
|
writeStream.on('error', reject);
|
||||||
|
writeStream.on('finish', resolve);
|
||||||
|
s3Stream.pipe(writeStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBytes,
|
||||||
|
downloadedBytes,
|
||||||
|
cacheExists: false
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubtitleTracks = (metadata) => {
|
||||||
|
return (metadata?.streams || [])
|
||||||
|
.filter((stream) => stream.codec_type === 'subtitle')
|
||||||
|
.map((stream, orderIndex) => {
|
||||||
|
const codec = (stream.codec_name || '').toLowerCase();
|
||||||
|
const language = stream.tags?.language || '';
|
||||||
|
const title = stream.tags?.title || '';
|
||||||
|
const supported = SUPPORTED_TEXT_SUBTITLE_CODECS.has(codec);
|
||||||
|
const labelParts = [
|
||||||
|
language ? language.toUpperCase() : `Subtitle ${orderIndex + 1}`,
|
||||||
|
title || null,
|
||||||
|
codec ? codec : null,
|
||||||
|
supported ? null : 'Unsupported'
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: stream.index,
|
||||||
|
codec,
|
||||||
|
language,
|
||||||
|
title,
|
||||||
|
supported,
|
||||||
|
label: labelParts.join(' / ')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const probeFile = (filePath) => {
|
const probeFile = (filePath) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
ffmpeg.ffprobe(filePath, (err, metadata) => {
|
||||||
@@ -410,6 +474,75 @@ app.get('/api/config', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/subtitles', async (req, res) => {
|
||||||
|
const bucket = req.query.bucket;
|
||||||
|
const key = req.query.key;
|
||||||
|
const requestedStreamIndex = typeof req.query.streamIndex === 'string' ? req.query.streamIndex.trim() : '';
|
||||||
|
const startSeconds = Math.max(0, parseFloat(req.query.ss) || 0);
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
return res.status(400).json({ error: 'Bucket name is required' });
|
||||||
|
}
|
||||||
|
if (!key) {
|
||||||
|
return res.status(400).json({ error: 'Video key is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tmpInputPath } = getCachePathParts(bucket, key);
|
||||||
|
const auth = extractS3Credentials(req);
|
||||||
|
const s3Client = createS3Client(auth);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureSourceCached({ s3Client, bucket, key, tmpInputPath });
|
||||||
|
|
||||||
|
const metadata = await probeFile(tmpInputPath);
|
||||||
|
const tracks = getSubtitleTracks(metadata);
|
||||||
|
|
||||||
|
if (!requestedStreamIndex) {
|
||||||
|
return res.json({ tracks });
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericStreamIndex = parseInt(requestedStreamIndex, 10);
|
||||||
|
const matchedTrack = tracks.find((track) => track.index === numericStreamIndex);
|
||||||
|
if (!matchedTrack) {
|
||||||
|
return res.status(404).json({ error: 'Subtitle track not found' });
|
||||||
|
}
|
||||||
|
if (!matchedTrack.supported) {
|
||||||
|
return res.status(415).json({ error: 'Subtitle codec is not supported for WebVTT output', codec: matchedTrack.codec });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/vtt; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
|
||||||
|
const subtitleCommand = ffmpeg()
|
||||||
|
.input(tmpInputPath)
|
||||||
|
.outputOptions(['-map', `0:${numericStreamIndex}`, '-reset_timestamps', '1'])
|
||||||
|
.format('webvtt')
|
||||||
|
.noAudio()
|
||||||
|
.noVideo();
|
||||||
|
|
||||||
|
if (startSeconds > 0) {
|
||||||
|
if (typeof subtitleCommand.seekInput === 'function') {
|
||||||
|
subtitleCommand.seekInput(startSeconds);
|
||||||
|
} else {
|
||||||
|
subtitleCommand.inputOptions(['-ss', startSeconds.toString()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitleCommand
|
||||||
|
.on('error', (error) => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Failed to extract subtitles', detail: error.message });
|
||||||
|
} else {
|
||||||
|
res.destroy(error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.pipe(res, { end: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error serving subtitles:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to load subtitles', detail: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/clear-download-cache', (req, res) => {
|
app.post('/api/clear-download-cache', (req, res) => {
|
||||||
try {
|
try {
|
||||||
clearDownloadCache();
|
clearDownloadCache();
|
||||||
@@ -466,11 +599,7 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
const videoEncoder = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
|
const videoEncoder = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
|
||||||
const requestedVideoDecoder = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
|
const requestedVideoDecoder = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
|
||||||
|
|
||||||
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
|
const { progressKey, tmpInputPath } = getCachePathParts(bucket, key);
|
||||||
const progressKey = safeKeySegments.join('/');
|
|
||||||
const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_');
|
|
||||||
const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
|
||||||
const cacheExists = fs.existsSync(tmpInputPath);
|
|
||||||
|
|
||||||
const auth = extractS3Credentials(req);
|
const auth = extractS3Credentials(req);
|
||||||
const s3Client = createS3Client(auth);
|
const s3Client = createS3Client(auth);
|
||||||
@@ -492,13 +621,9 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
|
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
let downloadedBytes = 0;
|
let downloadedBytes = 0;
|
||||||
|
const cacheExists = fs.existsSync(tmpInputPath);
|
||||||
|
|
||||||
if (!cacheExists) {
|
if (!cacheExists) {
|
||||||
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
|
||||||
const response = await s3Client.send(command);
|
|
||||||
const s3Stream = response.Body;
|
|
||||||
totalBytes = response.ContentLength || 0;
|
|
||||||
|
|
||||||
progressMap[progressKey] = {
|
progressMap[progressKey] = {
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
percent: 0,
|
percent: 0,
|
||||||
@@ -510,10 +635,14 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
};
|
};
|
||||||
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
|
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await ensureSourceCached({
|
||||||
const writeStream = fs.createWriteStream(tmpInputPath);
|
s3Client,
|
||||||
s3Stream.on('data', (chunk) => {
|
bucket,
|
||||||
downloadedBytes += chunk.length;
|
key,
|
||||||
|
tmpInputPath,
|
||||||
|
onProgress: ({ totalBytes: nextTotalBytes, downloadedBytes: nextDownloadedBytes }) => {
|
||||||
|
totalBytes = nextTotalBytes;
|
||||||
|
downloadedBytes = nextDownloadedBytes;
|
||||||
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',
|
||||||
@@ -526,11 +655,7 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
};
|
};
|
||||||
progressMap[progressKey] = downloadState;
|
progressMap[progressKey] = downloadState;
|
||||||
broadcastWs(progressKey, { type: 'progress', key, progress: downloadState });
|
broadcastWs(progressKey, { type: 'progress', key, progress: downloadState });
|
||||||
});
|
}
|
||||||
s3Stream.on('error', reject);
|
|
||||||
writeStream.on('error', reject);
|
|
||||||
writeStream.on('finish', resolve);
|
|
||||||
s3Stream.pipe(writeStream);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
progressMap[progressKey] = {
|
progressMap[progressKey] = {
|
||||||
|
|||||||
Reference in New Issue
Block a user