增加自定义字幕上传
This commit is contained in:
+163
-83
@@ -60,6 +60,10 @@ import {
|
||||
recommendationCacheKeys,
|
||||
setRecommendationCache,
|
||||
} from '@/lib/recommendations/cache';
|
||||
import {
|
||||
convertSubtitleFileToVttObjectUrl,
|
||||
CUSTOM_SUBTITLE_ACCEPT,
|
||||
} from '@/lib/subtitle-converter';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
import { DanmakuFilterConfig, EpisodeFilterConfig, SearchResult } from '@/lib/types';
|
||||
import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
@@ -116,6 +120,13 @@ interface SearchCachePayload {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface CustomSubtitleState {
|
||||
name: string;
|
||||
url: string;
|
||||
format: string;
|
||||
episodeIndex: number;
|
||||
}
|
||||
|
||||
function PlayPageClient() {
|
||||
const LOCAL_TRANSCODER_BASE_URL = 'http://localhost:19080';
|
||||
const router = useRouter();
|
||||
@@ -1782,6 +1793,9 @@ function PlayPageClient() {
|
||||
|
||||
const artPlayerRef = useRef<any>(null);
|
||||
const artRef = useRef<HTMLDivElement | null>(null);
|
||||
const customSubtitleInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const customSubtitleRef = useRef<CustomSubtitleState | null>(null);
|
||||
const currentSubtitleLabelRef = useRef<string>('关闭');
|
||||
|
||||
// Wake Lock 相关
|
||||
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||
@@ -1803,6 +1817,137 @@ function PlayPageClient() {
|
||||
// 工具函数(Utils)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const getSubtitleStyle = () => ({
|
||||
color: '#fff',
|
||||
fontSize: typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em',
|
||||
});
|
||||
|
||||
const revokeCustomSubtitle = () => {
|
||||
if (customSubtitleRef.current) {
|
||||
URL.revokeObjectURL(customSubtitleRef.current.url);
|
||||
customSubtitleRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const switchSubtitle = (url: string, label: string) => {
|
||||
if (!artPlayerRef.current) return;
|
||||
|
||||
artPlayerRef.current.subtitle.switch(url, {
|
||||
name: label,
|
||||
type: 'vtt',
|
||||
style: getSubtitleStyle(),
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
artPlayerRef.current.subtitle.show = true;
|
||||
currentSubtitleLabelRef.current = label;
|
||||
};
|
||||
|
||||
const removeSubtitleSetting = () => {
|
||||
try {
|
||||
artPlayerRef.current?.setting.remove('subtitle-selector');
|
||||
} catch (e) {
|
||||
// 忽略错误,可能设置项不存在
|
||||
}
|
||||
};
|
||||
|
||||
const updateSubtitleSetting = () => {
|
||||
if (!artPlayerRef.current) return;
|
||||
|
||||
const sourceSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndexRef.current] || [];
|
||||
const customSubtitle =
|
||||
customSubtitleRef.current?.episodeIndex === currentEpisodeIndexRef.current
|
||||
? customSubtitleRef.current
|
||||
: null;
|
||||
|
||||
removeSubtitleSetting();
|
||||
|
||||
const subtitleOptions = [
|
||||
{ html: '关闭', action: 'close' },
|
||||
{ html: '上传本地字幕', action: 'upload' },
|
||||
...sourceSubtitles.map((sub: any) => ({
|
||||
html: sub.label,
|
||||
action: 'switch',
|
||||
url: sub.url,
|
||||
})),
|
||||
...(customSubtitle
|
||||
? [
|
||||
{
|
||||
html: `本地:${customSubtitle.name}`,
|
||||
action: 'switch',
|
||||
url: customSubtitle.url,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
artPlayerRef.current.setting.add({
|
||||
name: 'subtitle-selector',
|
||||
html: '字幕',
|
||||
selector: subtitleOptions,
|
||||
onSelect: function (item: any) {
|
||||
if (!artPlayerRef.current) {
|
||||
return currentSubtitleLabelRef.current;
|
||||
}
|
||||
|
||||
if (item.action === 'close') {
|
||||
artPlayerRef.current.subtitle.show = false;
|
||||
currentSubtitleLabelRef.current = '关闭';
|
||||
return item.html;
|
||||
}
|
||||
|
||||
if (item.action === 'upload') {
|
||||
customSubtitleInputRef.current?.click();
|
||||
return currentSubtitleLabelRef.current;
|
||||
}
|
||||
|
||||
if (item.url) {
|
||||
switchSubtitle(item.url, item.html);
|
||||
return item.html;
|
||||
}
|
||||
|
||||
return currentSubtitleLabelRef.current;
|
||||
},
|
||||
default: currentSubtitleLabelRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCustomSubtitleFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const convertedSubtitle = await convertSubtitleFileToVttObjectUrl(file);
|
||||
revokeCustomSubtitle();
|
||||
|
||||
customSubtitleRef.current = {
|
||||
...convertedSubtitle,
|
||||
episodeIndex: currentEpisodeIndexRef.current,
|
||||
};
|
||||
|
||||
switchSubtitle(
|
||||
convertedSubtitle.url,
|
||||
`本地:${convertedSubtitle.name}`
|
||||
);
|
||||
updateSubtitleSetting();
|
||||
setToast({
|
||||
message: `已加载本地字幕:${convertedSubtitle.name}`,
|
||||
type: 'success',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Subtitle] 自定义字幕加载失败:', error);
|
||||
setToast({
|
||||
message: error instanceof Error ? error.message : '字幕加载失败',
|
||||
type: 'error',
|
||||
onClose: () => setToast(null),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 判断剧集状态
|
||||
const getSeriesStatus = (detail: SearchResult | null): 'completed' | 'ongoing' | 'unknown' => {
|
||||
if (!detail) return 'unknown';
|
||||
@@ -2953,6 +3098,8 @@ function PlayPageClient() {
|
||||
|
||||
// 清理播放器资源的统一函数
|
||||
const cleanupPlayer = async () => {
|
||||
revokeCustomSubtitle();
|
||||
|
||||
// 清除刷新定时器
|
||||
clearRefreshTimer();
|
||||
|
||||
@@ -4342,62 +4489,18 @@ function PlayPageClient() {
|
||||
useEffect(() => {
|
||||
if (!artPlayerRef.current || !detail) return;
|
||||
|
||||
revokeCustomSubtitle();
|
||||
const currentSubtitles = detail.subtitles?.[currentEpisodeIndex] || [];
|
||||
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
|
||||
|
||||
// 如果有字幕,更新播放器字幕
|
||||
if (currentSubtitles.length > 0) {
|
||||
artPlayerRef.current.subtitle.switch(currentSubtitles[0].url, {
|
||||
type: 'vtt',
|
||||
style: {
|
||||
color: '#fff',
|
||||
fontSize: savedSubtitleSize,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
// 移除旧的字幕设置,添加新的
|
||||
try {
|
||||
artPlayerRef.current.setting.remove('subtitle-selector');
|
||||
} catch (e) {
|
||||
// 忽略错误,可能设置项不存在
|
||||
}
|
||||
|
||||
const subtitleOptions = [
|
||||
{ html: '关闭', url: '' },
|
||||
...currentSubtitles.map((sub: any) => ({
|
||||
html: sub.label,
|
||||
url: sub.url,
|
||||
})),
|
||||
];
|
||||
|
||||
artPlayerRef.current.setting.add({
|
||||
name: 'subtitle-selector',
|
||||
html: '字幕',
|
||||
selector: subtitleOptions,
|
||||
onSelect: function (item: any) {
|
||||
if (artPlayerRef.current) {
|
||||
if (item.url === '') {
|
||||
artPlayerRef.current.subtitle.show = false;
|
||||
} else {
|
||||
artPlayerRef.current.subtitle.switch(item.url, {
|
||||
name: item.html,
|
||||
});
|
||||
artPlayerRef.current.subtitle.show = true;
|
||||
}
|
||||
}
|
||||
return item.html;
|
||||
},
|
||||
});
|
||||
switchSubtitle(currentSubtitles[0].url, currentSubtitles[0].label);
|
||||
} else {
|
||||
// 没有字幕时,隐藏字幕并移除字幕设置
|
||||
artPlayerRef.current.subtitle.show = false;
|
||||
try {
|
||||
artPlayerRef.current.setting.remove('subtitle-selector');
|
||||
} catch (e) {
|
||||
// 忽略错误,可能设置项不存在
|
||||
}
|
||||
currentSubtitleLabelRef.current = '关闭';
|
||||
}
|
||||
|
||||
updateSubtitleSetting();
|
||||
}, [detail, currentEpisodeIndex]);
|
||||
|
||||
const getSourceSwitchResumeTime = async (
|
||||
@@ -6167,6 +6270,7 @@ function PlayPageClient() {
|
||||
// 获取当前集的字幕
|
||||
const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || [];
|
||||
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
|
||||
currentSubtitleLabelRef.current = currentSubtitles[0]?.label || '关闭';
|
||||
|
||||
artPlayerRef.current = new Artplayer({
|
||||
container: artRef.current!,
|
||||
@@ -7298,40 +7402,8 @@ function PlayPageClient() {
|
||||
|
||||
applyProgressThumbConfig();
|
||||
|
||||
// 添加字幕切换功能
|
||||
const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || [];
|
||||
if (currentSubtitles.length > 0 && artPlayerRef.current) {
|
||||
const subtitleOptions = [
|
||||
{
|
||||
html: '关闭',
|
||||
url: '',
|
||||
},
|
||||
...currentSubtitles.map((sub: any) => ({
|
||||
html: sub.label,
|
||||
url: sub.url,
|
||||
})),
|
||||
];
|
||||
|
||||
artPlayerRef.current.setting.add({
|
||||
html: '字幕',
|
||||
selector: subtitleOptions,
|
||||
onSelect: function (item: any) {
|
||||
if (artPlayerRef.current) {
|
||||
if (item.url === '') {
|
||||
// 关闭字幕
|
||||
artPlayerRef.current.subtitle.show = false;
|
||||
} else {
|
||||
// 切换字幕
|
||||
artPlayerRef.current.subtitle.switch(item.url, {
|
||||
name: item.html,
|
||||
});
|
||||
artPlayerRef.current.subtitle.show = true;
|
||||
}
|
||||
}
|
||||
return item.html;
|
||||
},
|
||||
});
|
||||
}
|
||||
// 添加字幕切换和本地字幕上传功能
|
||||
updateSubtitleSetting();
|
||||
|
||||
// 添加字幕大小设置
|
||||
if (artPlayerRef.current) {
|
||||
@@ -9919,6 +9991,14 @@ function PlayPageClient() {
|
||||
{/* Toast通知 */}
|
||||
{toast && <Toast {...toast} />}
|
||||
|
||||
<input
|
||||
ref={customSubtitleInputRef}
|
||||
type='file'
|
||||
accept={CUSTOM_SUBTITLE_ACCEPT}
|
||||
className='hidden'
|
||||
onChange={handleCustomSubtitleFileChange}
|
||||
/>
|
||||
|
||||
{/* 下载选集面板 */}
|
||||
<DownloadEpisodeSelector
|
||||
isOpen={showDownloadSelector}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
export const CUSTOM_SUBTITLE_ACCEPT =
|
||||
'.vtt,.srt,.ass,.ssa,.ttml,.dfxp,.xml,.sbv,.sub,.lrc';
|
||||
|
||||
export interface ConvertedSubtitle {
|
||||
name: string;
|
||||
url: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
const WEBVTT_TIMESTAMP_PATTERN =
|
||||
/(\d{1,2}:)?\d{2}:\d{2}[,.]\d{1,3}\s*-->\s*(\d{1,2}:)?\d{2}:\d{2}[,.]\d{1,3}/;
|
||||
|
||||
export async function convertSubtitleFileToVttObjectUrl(
|
||||
file: File
|
||||
): Promise<ConvertedSubtitle> {
|
||||
const extension = getSubtitleExtension(file.name);
|
||||
if (!extension) {
|
||||
throw new Error('不支持的字幕格式');
|
||||
}
|
||||
|
||||
const text = await file.text();
|
||||
const vtt = convertSubtitleTextToVtt(text, extension);
|
||||
const blob = new Blob([vtt], { type: 'text/vtt;charset=utf-8' });
|
||||
|
||||
return {
|
||||
name: file.name,
|
||||
url: URL.createObjectURL(blob),
|
||||
format: extension,
|
||||
};
|
||||
}
|
||||
|
||||
export function convertSubtitleTextToVtt(text: string, format: string): string {
|
||||
const normalizedFormat = format.toLowerCase().replace(/^\./, '');
|
||||
const normalizedText = stripBom(text).replace(/\r\n?/g, '\n');
|
||||
|
||||
switch (normalizedFormat) {
|
||||
case 'vtt':
|
||||
return normalizeVtt(normalizedText);
|
||||
case 'srt':
|
||||
return convertSrtToVtt(normalizedText);
|
||||
case 'ass':
|
||||
case 'ssa':
|
||||
return convertAssToVtt(normalizedText);
|
||||
case 'ttml':
|
||||
case 'dfxp':
|
||||
case 'xml':
|
||||
return convertTtmlToVtt(normalizedText);
|
||||
case 'sbv':
|
||||
return convertSbvToVtt(normalizedText);
|
||||
case 'sub':
|
||||
return convertSubToVtt(normalizedText);
|
||||
case 'lrc':
|
||||
return convertLrcToVtt(normalizedText);
|
||||
default:
|
||||
throw new Error('不支持的字幕格式');
|
||||
}
|
||||
}
|
||||
|
||||
function getSubtitleExtension(fileName: string): string | null {
|
||||
const match = fileName.toLowerCase().match(/\.([a-z0-9]+)$/);
|
||||
const extension = match?.[1] || '';
|
||||
|
||||
return CUSTOM_SUBTITLE_ACCEPT.split(',')
|
||||
.map((item) => item.replace('.', ''))
|
||||
.includes(extension)
|
||||
? extension
|
||||
: null;
|
||||
}
|
||||
|
||||
function stripBom(text: string): string {
|
||||
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
||||
}
|
||||
|
||||
function normalizeVtt(text: string): string {
|
||||
const body = text.replace(/^WEBVTT[^\n]*(\n|$)/i, '').trim();
|
||||
return `WEBVTT\n\n${body}\n`;
|
||||
}
|
||||
|
||||
function convertSrtToVtt(text: string): string {
|
||||
return `WEBVTT\n\n${text
|
||||
.replace(/(\d{2}:\d{2}:\d{2}),(\d{1,3})/g, (_, time, ms) => {
|
||||
return `${time}.${ms.padEnd(3, '0').slice(0, 3)}`;
|
||||
})
|
||||
.trim()}\n`;
|
||||
}
|
||||
|
||||
function convertAssToVtt(text: string): string {
|
||||
const formatFields = getAssFormatFields(text);
|
||||
const events = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^Dialogue:/i.test(line))
|
||||
.map((line) => parseAssDialogue(line, formatFields))
|
||||
.filter((cue): cue is VttCue => Boolean(cue));
|
||||
|
||||
return cuesToVtt(events);
|
||||
}
|
||||
|
||||
function getAssFormatFields(text: string): string[] {
|
||||
const eventSection = text.split(/\n\s*\[Events\]\s*\n/i)[1] || text;
|
||||
const formatLine = eventSection
|
||||
.split('\n')
|
||||
.find((line) => /^Format:/i.test(line.trim()));
|
||||
|
||||
if (!formatLine) {
|
||||
return ['Layer', 'Start', 'End', 'Style', 'Name', 'MarginL', 'MarginR', 'MarginV', 'Effect', 'Text'];
|
||||
}
|
||||
|
||||
return formatLine
|
||||
.replace(/^Format:\s*/i, '')
|
||||
.split(',')
|
||||
.map((field) => field.trim());
|
||||
}
|
||||
|
||||
interface VttCue {
|
||||
start: string;
|
||||
end: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function parseAssDialogue(line: string, fields: string[]): VttCue | null {
|
||||
const payload = line.replace(/^Dialogue:\s*/i, '');
|
||||
const textIndex = fields.findIndex((field) => /^text$/i.test(field));
|
||||
const startIndex = fields.findIndex((field) => /^start$/i.test(field));
|
||||
const endIndex = fields.findIndex((field) => /^end$/i.test(field));
|
||||
|
||||
if (textIndex < 0 || startIndex < 0 || endIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = splitAssCsv(payload, fields.length);
|
||||
const start = assTimeToVtt(parts[startIndex]);
|
||||
const end = assTimeToVtt(parts[endIndex]);
|
||||
const text = cleanAssText(parts[textIndex]);
|
||||
|
||||
if (!start || !end || !text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end, text };
|
||||
}
|
||||
|
||||
function splitAssCsv(value: string, expectedParts: number): string[] {
|
||||
const parts = value.split(',');
|
||||
if (parts.length <= expectedParts) {
|
||||
return parts.map((part) => part.trim());
|
||||
}
|
||||
|
||||
return [
|
||||
...parts.slice(0, expectedParts - 1).map((part) => part.trim()),
|
||||
parts.slice(expectedParts - 1).join(',').trim(),
|
||||
];
|
||||
}
|
||||
|
||||
function cleanAssText(text: string): string {
|
||||
return decodeHtmlEntities(
|
||||
text
|
||||
.replace(/\{[^}]*\}/g, '')
|
||||
.replace(/\\[Nn]/g, '\n')
|
||||
.replace(/\\h/g, ' ')
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function assTimeToVtt(value: string): string | null {
|
||||
const match = value.trim().match(/^(\d+):(\d{2}):(\d{2})[.](\d{1,2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
return `${match[1].padStart(2, '0')}:${match[2]}:${match[3]}.${match[4]
|
||||
.padEnd(3, '0')
|
||||
.slice(0, 3)}`;
|
||||
}
|
||||
|
||||
function convertTtmlToVtt(text: string): string {
|
||||
const cues: VttCue[] = [];
|
||||
const paragraphPattern = /<p\b([^>]*)>([\s\S]*?)<\/p>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = paragraphPattern.exec(text))) {
|
||||
const attrs = match[1];
|
||||
const start = readXmlTime(attrs, ['begin', 'start']);
|
||||
const end = readXmlTime(attrs, ['end']);
|
||||
const duration = readXmlTime(attrs, ['dur']);
|
||||
const cueText = decodeHtmlEntities(
|
||||
match[2]
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim()
|
||||
);
|
||||
|
||||
if (!start || !cueText) continue;
|
||||
|
||||
cues.push({
|
||||
start,
|
||||
end: end || addVttTimes(start, duration || '00:00:03.000'),
|
||||
text: cueText,
|
||||
});
|
||||
}
|
||||
|
||||
return cuesToVtt(cues);
|
||||
}
|
||||
|
||||
function readXmlTime(attrs: string, names: string[]): string | null {
|
||||
for (const name of names) {
|
||||
const match = attrs.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, 'i'));
|
||||
if (match) {
|
||||
return normalizeTimestamp(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function convertSbvToVtt(text: string): string {
|
||||
const cues: VttCue[] = [];
|
||||
const blocks = text.split(/\n{2,}/);
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
const timing = lines[0];
|
||||
|
||||
if (!timing || !timing.includes(',')) continue;
|
||||
|
||||
const [startRaw, endRaw] = timing.split(',').map((part) => part.trim());
|
||||
const start = normalizeTimestamp(startRaw);
|
||||
const end = normalizeTimestamp(endRaw);
|
||||
const cueText = lines.slice(1).join('\n').trim();
|
||||
|
||||
if (start && end && cueText) {
|
||||
cues.push({ start, end, text: cueText });
|
||||
}
|
||||
}
|
||||
|
||||
return cuesToVtt(cues);
|
||||
}
|
||||
|
||||
function convertSubToVtt(text: string): string {
|
||||
if (/^\{\d+\}\{\d+\}/m.test(text)) {
|
||||
return convertMicroDvdSubToVtt(text);
|
||||
}
|
||||
|
||||
if (WEBVTT_TIMESTAMP_PATTERN.test(text)) {
|
||||
return convertSrtToVtt(text);
|
||||
}
|
||||
|
||||
return convertSbvToVtt(text);
|
||||
}
|
||||
|
||||
function convertMicroDvdSubToVtt(text: string): string {
|
||||
const frameRate = 25;
|
||||
const cues = text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const match = line.match(/^\{(\d+)\}\{(\d+)\}(.*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const start = secondsToVttTime(Number(match[1]) / frameRate);
|
||||
const end = secondsToVttTime(Number(match[2]) / frameRate);
|
||||
const cueText = decodeHtmlEntities(match[3].replace(/\|/g, '\n').replace(/\{[^}]*\}/g, '').trim());
|
||||
|
||||
return cueText ? { start, end, text: cueText } : null;
|
||||
})
|
||||
.filter((cue): cue is VttCue => Boolean(cue));
|
||||
|
||||
return cuesToVtt(cues);
|
||||
}
|
||||
|
||||
function convertLrcToVtt(text: string): string {
|
||||
const entries = text
|
||||
.split('\n')
|
||||
.flatMap((line) => {
|
||||
const timeMatches = Array.from(line.matchAll(/\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g));
|
||||
const cueText = line.replace(/\[[^\]]+\]/g, '').trim();
|
||||
|
||||
return timeMatches.map((match) => ({
|
||||
seconds:
|
||||
Number(match[1]) * 60 +
|
||||
Number(match[2]) +
|
||||
Number((match[3] || '0').padEnd(3, '0').slice(0, 3)) / 1000,
|
||||
text: cueText,
|
||||
}));
|
||||
})
|
||||
.filter((entry) => entry.text)
|
||||
.sort((a, b) => a.seconds - b.seconds);
|
||||
|
||||
const cues = entries.map((entry, index) => ({
|
||||
start: secondsToVttTime(entry.seconds),
|
||||
end: secondsToVttTime(entries[index + 1]?.seconds || entry.seconds + 3),
|
||||
text: entry.text,
|
||||
}));
|
||||
|
||||
return cuesToVtt(cues);
|
||||
}
|
||||
|
||||
function cuesToVtt(cues: VttCue[]): string {
|
||||
if (cues.length === 0) {
|
||||
throw new Error('未识别到有效字幕内容');
|
||||
}
|
||||
|
||||
return `WEBVTT\n\n${cues
|
||||
.map((cue) => `${cue.start} --> ${cue.end}\n${cue.text}`)
|
||||
.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: string): string | null {
|
||||
const trimmed = value.trim().replace(',', '.');
|
||||
const offsetMatch = trimmed.match(/^(\d+(?:\.\d+)?)s$/i);
|
||||
if (offsetMatch) {
|
||||
return secondsToVttTime(Number(offsetMatch[1]));
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^(?:(\d{1,2}):)?(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const hours = match[1] || '0';
|
||||
const minutes = match[2];
|
||||
const seconds = match[3];
|
||||
const millis = (match[4] || '0').padEnd(3, '0').slice(0, 3);
|
||||
|
||||
return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}:${seconds}.${millis}`;
|
||||
}
|
||||
|
||||
function addVttTimes(start: string, duration: string): string {
|
||||
return secondsToVttTime(vttTimeToSeconds(start) + vttTimeToSeconds(duration));
|
||||
}
|
||||
|
||||
function vttTimeToSeconds(value: string): number {
|
||||
const match = value.match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/);
|
||||
if (!match) return 0;
|
||||
|
||||
return (
|
||||
Number(match[1]) * 3600 +
|
||||
Number(match[2]) * 60 +
|
||||
Number(match[3]) +
|
||||
Number(match[4]) / 1000
|
||||
);
|
||||
}
|
||||
|
||||
function secondsToVttTime(totalSeconds: number): string {
|
||||
const safeSeconds = Math.max(0, totalSeconds);
|
||||
const hours = Math.floor(safeSeconds / 3600);
|
||||
const minutes = Math.floor((safeSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(safeSeconds % 60);
|
||||
const millis = Math.round((safeSeconds - Math.floor(safeSeconds)) * 1000);
|
||||
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(
|
||||
seconds
|
||||
).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
Reference in New Issue
Block a user