SRT/VTT/ASS 字幕时间码修复:毫秒级偏移校准与格式转换实战

原文:https://dev.to/begoodtool/subtitle-timecode-fixer-format-converter-what-a-millisecond-offset-really-changes-3din(作者 @yuntao_lin)

最恼人的字幕 bug 往往是规整的那一种:每一行都提前了相同的时长。手动修改几十个时间戳风险不小,而在 SRT、VTT、ASS 之间互转格式又可能引入第二类错误。Subtitle Timecode Fixer & Format Converter 这个工具就是围绕一个更简单的模型构建的:把时间解析成毫秒,应用一个偏移量,再渲染回目标格式。

本文面向那些需要修复一批字幕条目、又不想把文件传到服务器的视频剪辑、字幕翻译或前端开发者。核心结论是:时间平移是一次算术运算,而格式转换则是一次有损决策。这个工具就是这两类操作在浏览器中的一次具体实现。

动手修改前,先检测格式

输入文本框由 detectFormat 监听,它通过结构特征来判断格式,而不是信任文件扩展名:

function detectFormat() {
  const text = inputText.value.trim();
  if (!text) { detectedFormat.value = ""; return; }
  if (/^WEBVTT/m.test(text)) {
    detectedFormat.value = "VTT";
  } else if (/^[Ss]cript [Ii]nfo|^\[V4\+ [Ss]tyles\]|^Dialogue:/m.test(text)) {
    detectedFormat.value = "ASS/SSA";
  } else if (/^\d+\s*\n\d{2}:\d{2}:\d{2},\d{3}\s*-->/m.test(text)) {
    detectedFormat.value = "SRT";
  } else {
    detectedFormat.value = "";
  }
}


这里有意做成"格式提示",而不是完整的校验器:WEBVTT 头部标识 VTT,Dialogue: 行是 ASS 的特征,SRT 条目则要求一个序号后跟逗号分隔的时间范围。如果一个都不匹配,处理会以"未知格式"的提示终止,而不是硬去"修复"任意文本。

把时间戳统一到同一个单位

SRT 和 VTT 的分隔符不同,但解析器会把两者都转换成毫秒。SRT 按 hh:mm:ss,mmm 拆分,VTT 则既接受 hh:mm:ss.mmm,也接受更短的 mm:ss.mmm 形式:

function srtTimeToMs(ts) {
  const [hms, ms] = ts.split(",");
  const [h, m, s] = hms.split(":").map(Number);
  return (h * 3600 + m * 60 + s) * 1000 + Number(ms);
}

function vttTimeToMs(ts) {
  const parts = ts.split(":");
  let h = 0, m = 0, s = 0;
  if (parts.length === 3) {
    [h, m] = [Number(parts[0]), Number(parts[1])];
    s = parseFloat(parts[2]);
  } else {
    [m] = [Number(parts[0])];
    s = parseFloat(parts[1]);
  }
  return Math.round((h * 3600 + m * 60 + s) * 1000);
}


当每一个条目(entry)的 startend 都换算成同一单位后,应用偏移就很简单了:

entries = entries.map(e => ({
  ...e,
  start: Math.max(0, e.start + offset),
  end: Math.max(0, e.end + offset),
}));


Math.max(0, ...) 这层保护很重要:负向提前字幕时,不应产生输出格式器无法表示的负时间戳。正值则会同时推迟两个边界,从而保留每条字幕的时长。

渲染阶段:格式之间不再等价

SRT 渲染会重新给条目编号并使用逗号毫秒;VTT 会加上 WEBVTT 头并改用句点;ASS 则保留原文件的所有行,只替换 Dialogue: 行上的开始和结束字段:

const assLines = text.split("\n");
let entryIdx = 0;
result = assLines.map(line => {
  if (/^Dialogue:/.test(line) && entryIdx < entries.length) {
    const e = entries[entryIdx++];
    const fields = line.split(",");
    fields[1] = msToAssTime(e.start);
    fields[2] = msToAssTime(e.end);
    return fields.join(",");
  }
  return line;
}).join("\n");


从 ASS 转 SRT 或 VTT 时,程序会提取文本字段、去除 {...} 覆写标签,并把 \N
转成换行。这样得到的纯字幕可读,但 ASS 的字体、颜色、定位、卡拉OK特效等样式无法在 SRT/VTT 中保留。因此保留原始文件是工作流的一部分,而不是可有可无的保险措施。

用毫秒来思考,也让修正方向不再神秘:字幕比说话人晚出现,说明字幕偏晚,偏移量应为负;字幕比声音早出现,偏移量则为正。界面上在数字输入框旁附有说明,并采用 100 ms 步进,但不会试图从媒体文件本身估算偏移——观察到的差值仍由人来提供,代码的职责是把它一致地应用到每一条解析出的字幕上。

限制与值得注意的边界情况

SRT 解析器按空行分块并查找包含 --> 的行;起始时间解析为 NaN 的畸形条目会被过滤掉。VTT 解析器从第一条计时行开始收集文本,直到遇到空行为止,因此不常见的 cue 元数据或非标准格式未必能完美往返转换。ASS 解析只接受正则表达式所匹配的那种 Dialogue: layer,start,end,... 结构,注释行和其他事件类型则原样保留。

浏览器端用 FileReader 读取上传的文件、用 navigator.clipboard 复制结果、下载生成的文本 blob。这让字幕内容始终留在本地,但在受限的浏览器环境里剪贴板权限仍可能失败。最后,单一固定偏移无法修复漂移随时间不断增大的字幕——那需要时间拉伸(time-stretch)或手动校正时间轴,而不是这种批量平移。

这套实现最后做成了一个免费的小工具:Subtitle Timecode Fixer & Format Converter

原文:https://dev.to/begoodtool/subtitle-timecode-fixer-format-converter-what-a-millisecond-offset-really-changes-3din(作者 @yuntao_lin)

发布评论
全部评论(0)