Skip to content

实现 字幕格式转换 工具

分类: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-inputel-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 效果截图

字幕格式转换 效果截图

工具访问地址:https://www.i91tools.com/tools/subtitle-convert