Appearance
实现 字幕格式转换 工具
分类:developer | 标签:字幕、SRT、VTT SRT/VTT/ASS/LRC 互转,时间轴平移与缩放
2.1 功能说明
SRT/VTT/ASS/LRC 互转,时间轴平移与缩放
2.1.1 使用指南
功能说明
纯浏览器端的字幕格式转换器,支持 SRT、VTT、LRC、ASS 四种常见字幕格式的互相转换,并可对时间轴做整体平移与缩放。所有解析与生成逻辑均为本工具自研实现,不依赖任何第三方库,字幕内容不会离开你的浏览器。
支持的格式
SRT:序号 + 00:00:01,000 --> 00:00:04,000 时间轴 + 多行文本,块与块之间用空行分隔。
VTT:以 WEBVTT 开头,时间轴用小数点分隔(00:00:01.000),允许 cue 标识行、NOTE 注释块与行尾定位参数,解析时会自动忽略这些附加信息。
LRC:每行形如 [mm:ss.xx]文本,支持一行挂多个时间标签,[ar:]、[ti:] 等元数据标签会被跳过。由于 LRC 只有起始时间,转出时每句的结束时间取下一句的开始时间(最后一句默认延长 3 秒)。
ASS/SSA:尽力而为的转换,只处理 [Events] 段里的 Dialogue 行,读取时间轴与文本,并去除 {...} 样式覆盖标记、把 \N 还原为换行。字体、颜色、定位、特效等样式信息会丢失,生成 ASS 时使用一套通用的默认样式。
时间轴调整
平移:输入偏移毫秒数,正数表示字幕整体延后,负数表示整体提前,用于修正整段字幕与视频的固定时间差。
缩放:输入倍率,用于修正因帧率不同导致的越往后偏差越大的问题。例如 25fps 与 23.976fps 之间常用 1.0427 或 0.9590,轻微漂移可尝试 1.05 / 0.95。
两者会同时生效,计算公式为:新时间 = 原时间 × 缩放倍率 + 平移毫秒,结果小于 0 时按 0 处理。
使用步骤
粘贴字幕文本或点击「选择文件」导入本地字幕文件,输入格式默认自动识别(也可手动指定),选择目标输出格式,按需填写平移与缩放,点击「转换」即可在右侧得到结果,随后可复制或下载为对应扩展名的文件。
注意事项
转换只保留时间轴与纯文本内容,SRT/VTT 中的 <b>、<i> 等内联标签会原样保留,而 ASS 的特效标记会被丢弃,属于有损转换,重要文件请自行备份原稿。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
pad()parseTimeToMs()msToSrt()msToVtt()msToAss()msToLrc()detectFormat()splitLines()parseCueTimeLine()parseTimedText()parseLrc()cleanAssText()parseAss()parseSubtitle()buildSrt()buildVtt()buildLrc()buildAss()convert()handleFile()copyOut()download()loadSample()clearAll()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
const input = ref('')
const output = ref('')
const error = ref('')
const info = ref('')
const inputFormat = ref('auto')
const outputFormat = ref('vtt')
const shiftMs = ref(0)
const scale = ref(1)
function pad(n, len) {
return String(Math.floor(Math.abs(n))).padStart(len || 2, '0')
}
function parseTimeToMs(raw) {
const s = String(raw || '').trim()
const m = s.match(/^(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?$/)
if (!m) return null
const h = m[1] ? parseInt(m[1], 10) : 0
const mi = parseInt(m[2], 10)
const se = parseInt(m[3], 10)
let frac = 0
if (m[4]) {
const f = m[4]
if (f.length === 1) frac = parseInt(f, 10) * 100
else if (f.length === 2) frac = parseInt(f, 10) * 10
else frac = parseInt(f, 10)
}
return h * 3600000 + mi * 60000 + se * 1000 + frac
}
function msToSrt(ms) {
const v = Math.max(0, Math.round(ms))
return pad(v / 3600000) + ':' + pad((v % 3600000) / 60000) + ':' + pad((v % 60000) / 1000) + ',' + pad(v % 1000, 3)
}
function msToVtt(ms) {
const v = Math.max(0, Math.round(ms))
return pad(v / 3600000) + ':' + pad((v % 3600000) / 60000) + ':' + pad((v % 60000) / 1000) + '.' + pad(v % 1000, 3)
}
function msToAss(ms) {
const v = Math.max(0, Math.round(ms))
return Math.floor(v / 3600000) + ':' + pad((v % 3600000) / 60000) + ':' + pad((v % 60000) / 1000) + '.' + pad((v % 1000) / 10, 2)
}
function msToLrc(ms) {
const v = Math.max(0, Math.round(ms))
return '[' + pad(v / 60000) + ':' + pad((v % 60000) / 1000) + '.' + pad((v % 1000) / 10, 2) + ']'
}
function detectFormat(text) {
const t = text || ''
if (/^\uFEFF?\s*WEBVTT/.test(t)) return 'vtt'
if (/\[Script Info\]/i.test(t) || /^\s*Dialogue\s*:/m.test(t)) return 'ass'
if (/-->/.test(t)) return /\d{1,2}:\d{2}:\d{2}\.\d{1,3}\s*-->/.test(t) ? 'vtt' : 'srt'
if (/^\s*\[\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\]/m.test(t)) return 'lrc'
return 'srt'
}
function splitLines(text) {
return String(text).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n')
}
function parseCueTimeLine(line) {
const parts = line.split('-->')
if (parts.length < 2) return null
const start = parseTimeToMs(parts[0])
const rightRaw = parts[1].trim()
const endToken = rightRaw.split(/\s+/)[0]
const end = parseTimeToMs(endToken)
if (start === null || end === null) return null
return { start, end }
}
// SRT 与 VTT 共用块结构,差别在于头部与附加块
function parseTimedText(text, isVtt) {
const lines = splitLines(text)
const cues = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (isVtt && /^\s*(WEBVTT|NOTE|STYLE|REGION)\b/.test(line)) {
// 跳过头部与附加块,直到空行
i++
while (i < lines.length && lines[i].trim() !== '') i++
continue
}
if (line.indexOf('-->') === -1) { i++; continue }
const time = parseCueTimeLine(line)
if (!time) { i++; continue }
i++
const textLines = []
while (i < lines.length && lines[i].trim() !== '') {
textLines.push(lines[i])
i++
}
cues.push({ start: time.start, end: time.end, text: textLines.join('\n').trim() })
}
return cues
}
function parseLrc(text) {
const lines = splitLines(text)
const items = []
const tagRe = /\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]/g
for (const line of lines) {
if (/^\s*\[[a-zA-Z#]+:/.test(line)) continue
tagRe.lastIndex = 0
const stamps = []
let m
while ((m = tagRe.exec(line))) {
const mi = parseInt(m[1], 10)
const rest = m[2].replace(':', '.')
const secParts = rest.split('.')
const se = parseInt(secParts[0], 10)
let frac = 0
if (secParts[1]) {
const f = secParts[1]
if (f.length === 1) frac = parseInt(f, 10) * 100
else if (f.length === 2) frac = parseInt(f, 10) * 10
else frac = parseInt(f, 10)
}
stamps.push(mi * 60000 + se * 1000 + frac)
}
if (!stamps.length) continue
const content = line.replace(tagRe, '').trim()
for (const st of stamps) items.push({ start: st, text: content })
}
items.sort((a, b) => a.start - b.start)
return items.map((it, idx) => ({
start: it.start,
end: idx + 1 < items.length ? Math.max(it.start + 200, items[idx + 1].start) : it.start + 3000,
text: it.text
}))
}
function cleanAssText(raw) {
return String(raw)
.replace(/\{[^}]*\}/g, '')
.replace(/\\[Nn]/g, '\n')
.replace(/\\h/g, ' ')
.trim()
}
function parseAss(text) {
const lines = splitLines(text)
const cues = []
let startIdx = 0
let endIdx = 0
let textIdx = 9
let inEvents = false
for (const line of lines) {
const trimmed = line.trim()
if (/^\[.+\]$/.test(trimmed)) {
inEvents = /^\[events\]$/i.test(trimmed)
continue
}
if (!inEvents) continue
if (/^Format\s*:/i.test(trimmed)) {
const fields = trimmed.slice(trimmed.indexOf(':') + 1).split(',').map(s => s.trim().toLowerCase())
const si = fields.indexOf('start')
const ei = fields.indexOf('end')
const ti = fields.indexOf('text')
if (si >= 0) startIdx = si
if (ei >= 0) endIdx = ei
if (ti >= 0) textIdx = ti
continue
}
if (!/^Dialogue\s*:/i.test(trimmed)) continue
const body = trimmed.slice(trimmed.indexOf(':') + 1)
const parts = body.split(',')
if (parts.length <= textIdx) continue
const start = parseTimeToMs(parts[startIdx])
const end = parseTimeToMs(parts[endIdx])
if (start === null || end === null) continue
const content = cleanAssText(parts.slice(textIdx).join(','))
if (!content) continue
cues.push({ start, end, text: content })
}
return cues
}
function parseSubtitle(text, format) {
if (format === 'lrc') return parseLrc(text)
if (format === 'ass') return parseAss(text)
return parseTimedText(text, format === 'vtt')
}
function buildSrt(cues) {
return cues.map((c, i) => (i + 1) + '\n' + msToSrt(c.start) + ' --> ' + msToSrt(c.end) + '\n' + c.text).join('\n\n') + '\n'
}
function buildVtt(cues) {
return 'WEBVTT\n\n' + cues.map(c => msToVtt(c.start) + ' --> ' + msToVtt(c.end) + '\n' + c.text).join('\n\n') + '\n'
}
function buildLrc(cues) {
return cues.map(c => msToLrc(c.start) + c.text.replace(/\n+/g, ' ')).join('\n') + '\n'
}
function buildAss(cues) {
const header = [
'[Script Info]',
'Title: Converted Subtitle',
'ScriptType: v4.00+',
'WrapStyle: 0',
'ScaledBorderAndShadow: yes',
'PlayResX: 1920',
'PlayResY: 1080',
'',
'[V4+ Styles]',
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
'Style: Default,Arial,64,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,2,60,60,40,1',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text'
].join('\n')
const body = cues.map(c =>
'Dialogue: 0,' + msToAss(c.start) + ',' + msToAss(c.end) + ',Default,,0,0,0,,' + c.text.replace(/\n/g, '\\N')
).join('\n')
return header + '\n' + body + '\n'
}
function convert() {
error.value = ''
info.value = ''
output.value = ''
const raw = input.value
if (!raw.trim()) { error.value = '请输入或导入字幕内容'; return }
const fmt = inputFormat.value === 'auto' ? detectFormat(raw) : inputFormat.value
let cues
try {
cues = parseSubtitle(raw, fmt)
} catch (e) {
error.value = '解析失败:' + e.message
return
}
if (!cues.length) { error.value = '未解析到任何字幕条目,请检查输入格式是否为 ' + fmt.toUpperCase(); return }
const k = Number(scale.value) || 1
const d = Number(shiftMs.value) || 0
const adjusted = cues.map(c => {
const start = Math.max(0, c.start * k + d)
let end = Math.max(0, c.end * k + d)
if (end <= start) end = start + 100
return { start, end, text: c.text }
}).sort((a, b) => a.start - b.start)
const out = outputFormat.value
if (out === 'srt') output.value = buildSrt(adjusted)
else if (out === 'vtt') output.value = buildVtt(adjusted)
else if (out === 'lrc') output.value = buildLrc(adjusted)
else output.value = buildAss(adjusted)
const last = adjusted[adjusted.length - 1]
const extra = out === 'ass' ? ',ASS 为尽力转换(仅时间轴与文本)' : ''
info.value = '识别为 ' + fmt.toUpperCase() + ',共 ' + adjusted.length + ' 条,时长约 ' + msToSrt(last.end) + extra
}
function handleFile(uploadFile) {
const file = uploadFile && uploadFile.raw ? uploadFile.raw : uploadFile
if (!file) return
const reader = new FileReader()
reader.onload = () => {
input.value = String(reader.result || '')
const name = String(file.name || '').toLowerCase()
if (name.endsWith('.srt')) inputFormat.value = 'srt'
else if (name.endsWith('.vtt')) inputFormat.value = 'vtt'
else if (name.endsWith('.lrc')) inputFormat.value = 'lrc'
else if (name.endsWith('.ass') || name.endsWith('.ssa')) inputFormat.value = 'ass'
else inputFormat.value = 'auto'
convert()
}
reader.onerror = () => { error.value = '文件读取失败' }
reader.readAsText(file, 'utf-8')
}
function copyOut() {
if (!output.value) return
if (navigator.clipboard) {
navigator.clipboard.writeText(output.value).then(() => { info.value = '已复制到剪贴板' })
}
}
function download() {
if (!output.value) return
const extMap = { srt: 'srt', vtt: 'vtt', lrc: 'lrc', ass: 'ass' }
const blob = new Blob([output.value], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'subtitle.' + extMap[outputFormat.value]
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function loadSample() {
input.value = [
'1',
'00:00:01,000 --> 00:00:04,000',
'欢迎使用字幕格式转换工具',
'',
'2',
'00:00:04,500 --> 00:00:08,200',
'支持 SRT、VTT、LRC 与 ASS',
'可以调整时间轴',
'',
'3',
'00:00:09,000 --> 00:00:12,000',
'全部处理都在本地完成'
].join('\n')
inputFormat.value = 'auto'
convert()
}
function clearAll() {
input.value = ''
output.value = ''
error.value = ''
info.value = ''
shiftMs.value = 0
scale.value = 1
}2.3 效果截图
