160 lines
4.9 KiB
TypeScript
160 lines
4.9 KiB
TypeScript
// SRT subtitle format parsing and export utilities
|
|
|
|
export interface SrtCue {
|
|
index: number
|
|
startTime: number // in seconds
|
|
endTime: number // in seconds
|
|
text: string
|
|
color?: string // extracted from <font color=...> tags if present
|
|
}
|
|
|
|
/**
|
|
* Parse SRT timestamp to seconds
|
|
* Format: HH:MM:SS,mmm (e.g. "00:01:23,456")
|
|
*/
|
|
function parseTimestamp(ts: string): number {
|
|
const match = ts.trim().match(/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/)
|
|
if (!match) return 0
|
|
const [, h, m, s, ms] = match
|
|
return parseInt(h) * 3600 + parseInt(m) * 60 + parseInt(s) + parseInt(ms) / 1000
|
|
}
|
|
|
|
/**
|
|
* Convert seconds to SRT timestamp
|
|
* Returns format: HH:MM:SS,mmm
|
|
*/
|
|
function formatTimestamp(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600)
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
const s = Math.floor(seconds % 60)
|
|
const ms = Math.round((seconds % 1) * 1000)
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')},${String(ms).padStart(3, '0')}`
|
|
}
|
|
|
|
/**
|
|
* Strip HTML/rich-text tags from SRT text.
|
|
* Handles <font color=...>, <b>, <i>, <u>, and any other HTML tags.
|
|
* Extracts the first color value found (if any).
|
|
*/
|
|
function stripTags(text: string): { clean: string; color?: string } {
|
|
let color: string | undefined
|
|
|
|
// Extract color from <font color=...> (Premiere format: color=#RRGGBBAA or #RRGGBB)
|
|
const colorMatch = text.match(/<font\s+color\s*=\s*["']?([^"'>]+)["']?\s*>/i)
|
|
if (colorMatch) {
|
|
let c = colorMatch[1].trim()
|
|
// Premiere sometimes outputs 8-char hex (#RRGGBBAA) — convert to standard 6-char
|
|
if (/^#[0-9A-Fa-f]{8}$/.test(c)) {
|
|
c = c.slice(0, 7) // drop the alpha suffix
|
|
}
|
|
color = c
|
|
}
|
|
|
|
// Strip all HTML tags
|
|
const clean = text
|
|
.replace(/<[^>]+>/g, '') // remove tags
|
|
.replace(/\n\s*\n/g, '\n') // collapse blank lines left by removed tags
|
|
.trim()
|
|
|
|
return { clean, color }
|
|
}
|
|
|
|
// Threshold: cues shorter than this (in seconds) are considered "pre-cues" / fade markers
|
|
const PRE_CUE_THRESHOLD = 0.1 // 100ms
|
|
|
|
/**
|
|
* Parse an SRT file content string into an array of cues.
|
|
*
|
|
* Handles:
|
|
* - Standard SRT format
|
|
* - Premiere Pro SRT with <font color=...> tags
|
|
* - Premiere "pre-cue" pairs (near-zero-duration fade-in marker + real cue)
|
|
* → merged into a single cue using the pre-cue's start time and the real cue's end time
|
|
*/
|
|
export function parseSrt(content: string): SrtCue[] {
|
|
const rawCues: SrtCue[] = []
|
|
|
|
// Normalize line endings
|
|
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
|
|
|
|
// Split into blocks separated by empty lines
|
|
const blocks = normalized.split(/\n\n+/)
|
|
|
|
for (const block of blocks) {
|
|
const lines = block.trim().split('\n')
|
|
if (lines.length < 3) continue
|
|
|
|
// First line: index number
|
|
const index = parseInt(lines[0].trim())
|
|
if (isNaN(index)) continue
|
|
|
|
// Second line: timestamps (start --> end)
|
|
const timeParts = lines[1].split('-->')
|
|
if (timeParts.length !== 2) continue
|
|
|
|
const startTime = parseTimestamp(timeParts[0])
|
|
const endTime = parseTimestamp(timeParts[1])
|
|
|
|
if (endTime <= startTime) continue
|
|
|
|
// Remaining lines: subtitle text (strip HTML tags)
|
|
const rawText = lines.slice(2).join('\n').trim()
|
|
if (!rawText) continue
|
|
|
|
const { clean, color } = stripTags(rawText)
|
|
if (!clean) continue
|
|
|
|
rawCues.push({ index, startTime, endTime, text: clean, color })
|
|
}
|
|
|
|
// --- Merge Premiere-style pre-cue pairs ---
|
|
// Pattern: a near-zero-duration cue immediately followed by a cue with the same text.
|
|
// The first cue's start time is the real start; the second cue's end time is the real end.
|
|
const merged: SrtCue[] = []
|
|
let i = 0
|
|
while (i < rawCues.length) {
|
|
const cur = rawCues[i]
|
|
const next = rawCues[i + 1]
|
|
|
|
const curDuration = cur.endTime - cur.startTime
|
|
|
|
if (
|
|
next &&
|
|
curDuration <= PRE_CUE_THRESHOLD &&
|
|
cur.text === next.text &&
|
|
Math.abs(cur.endTime - next.startTime) < 0.1 // consecutive
|
|
) {
|
|
// Merge: use pre-cue's start + real cue's end
|
|
merged.push({
|
|
index: cur.index,
|
|
startTime: cur.startTime,
|
|
endTime: next.endTime,
|
|
text: cur.text,
|
|
color: cur.color || next.color,
|
|
})
|
|
i += 2 // skip both
|
|
} else if (curDuration <= PRE_CUE_THRESHOLD) {
|
|
// Standalone near-zero cue with no matching follow-up — skip it (likely orphan pre-cue)
|
|
i++
|
|
} else {
|
|
merged.push(cur)
|
|
i++
|
|
}
|
|
}
|
|
|
|
// Re-index
|
|
return merged.map((cue, idx) => ({ ...cue, index: idx + 1 }))
|
|
}
|
|
|
|
/**
|
|
* Export an array of cues to SRT format string
|
|
*/
|
|
export function exportSrt(cues: { startTime: number; endTime: number; text: string }[]): string {
|
|
// Sort by start time
|
|
const sorted = [...cues].sort((a, b) => a.startTime - b.startTime)
|
|
|
|
return sorted.map((cue, i) => {
|
|
return `${i + 1}\n${formatTimestamp(cue.startTime)} --> ${formatTimestamp(cue.endTime)}\n${cue.text}`
|
|
}).join('\n\n') + '\n'
|
|
}
|