我们出了一批带字幕的视频,交付后客户退回来说字幕有问题。我一看,问题五花八门:
- 有一行只显示 0.4 秒——根本来不及看;
- 有两行时间重叠——屏幕上同时显示两行;
- 有几行超出屏幕宽度——在小屏幕上被截断;
- 有两个文件乱码——编码问题;
- 还有一整条字幕比音频早了 1.5 秒——时间轴系统性偏移。
这些问题人工看很难发现(尤其是"0.4 秒"这种),但它们会让观众觉得"这字幕做得不专业"。
后来我写了个检查脚本,交付前自动跑一遍,输出问题清单。这篇写完整的检查项、行业参考阈值和脚本实现。
TL;DR:字幕验收的检查项:最短显示时长(≥ 0.7~0.83 秒)、最长显示时长(≤ 7 秒)、阅读速度(中文 ≤ 9 字/秒,英文 ≤ 20 字符/秒)、每行字符数(中文 ≤ 16~20,英文 ≤ 42)、最多两行、行间隔(≥ 2 帧)、重叠检测、编码检查(必须是 UTF-8)、系统性偏移检测(与音频对齐对比)。脚本解析 SRT/ASS 后逐项检查,输出 CSV 报告,用退出码接入交付流程。能自动修的(过短延长、过长拆分)可以自动处理,内容问题必须人工。
目录
- 一、字幕质量的九类问题
- 二、先说格式:SRT 与 ASS
- 三、检查项与阈值
- 四、脚本实现(SRT)
- 五、编码检查:乱码的根源
- 六、系统性偏移检测
- 七、ASS 的额外检查
- 八、接入交付流程
- 九、能自动修什么
- 十、坑清单
一、字幕质量的九类问题
| 问题 | 表现 | 严重度 |
|---|---|---|
| 显示过短 | 一闪而过,看不清 | 高 |
| 显示过长 | 停留太久,与画面脱节 | 中 |
| 阅读速度过快 | 字数多但时间短 | 高 |
| 行数过多 | 3 行以上,遮挡画面 | 中 |
| 每行过长 | 超出屏幕宽度 | 高 |
| 时间重叠 | 两行同时显示 | 高 |
| 间隔过小 | 两行之间几乎没有空隙,视觉上像连在一起 | 中 |
| 乱码 | 编码错误 | 高 |
| 系统性偏移 | 整体早/晚,与语音对不上 | 高 |
这些问题里,"显示过短"和"阅读速度过快"是最常见也最影响体验的——尤其是自动生成的字幕(ASR 输出经常产生 0.3 秒的碎片行)。
二、先说格式:SRT 与 ASS
SRT(最简单通用):
1
00:00:01,000 --> 00:00:04,000
这是第一行字幕
可以有第二行
2
00:00:05,000 --> 00:00:08,500
第二条
- 序号;
- 时间行
HH:MM:SS,mmm --> HH:MM:SS,mmm(注意逗号是毫秒分隔符); - 文本(可以多行);
- 空行分隔。
ASS(功能强,带样式):
[Events]
Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,这是字幕
- 时间格式
H:MM:SS.cc(点号分隔百分秒); - 有样式、定位、特效。
解析时要区分这两种格式的时间分隔符(逗号 vs 点号)——这是个常见的解析 bug 来源。
三、检查项与阈值
参考行业通行规范(Netflix、广播字幕规范,各略有差异):
| 检查项 | 阈值 | 说明 |
|---|---|---|
| 最短显示时长 | ≥ 0.7 s(建议 0.833s = 20 帧@24fps) | 低于这个来不及读 |
| 最长显示时长 | ≤ 7 s | 太久说明没断句 |
| 最短间隔 | ≥ 0.08 s(2 帧) | 两行之间要有空隙 |
| 阅读速度(中文) | ≤ 9 字/秒 | 含标点 |
| 阅读速度(英文) | ≤ 20 字符/秒(CPS) | |
| 每行字符(中文) | ≤ 16~20 | 视屏幕宽度 |
| 每行字符(英文) | ≤ 42 | |
| 最大行数 | 2 | |
| 重叠 | 0 | 时间区间不能交叉 |
中文的阈值要单独说明:中文信息密度高(一个字的信息量约等于英文两个词),所以:
- 中文每秒 7~9 字 是舒适区(英文是 17~20 CPS);
- 中文每行 16~20 字(英文 42 字符)。
这些阈值不是硬性的——不同平台(短视频 vs 电影)差异很大。短视频的字幕可以更短更快(观众习惯快节奏),电影则可以慢一些。关键是"定一个标准并一致执行",而不是追求某个绝对正确的数字。
四、脚本实现(SRT)
import re
import csv
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Cue:
index: int
start: float
end: float
lines: list
def parse_time(t: str) -> float:
"""'00:01:02,500' -> 62.5"""
t = t.strip().replace(',', '.')
h, m, rest = t.split(':')
return int(h) * 3600 + int(m) * 60 + float(rest)
def parse_srt(path: str) -> list:
text = Path(path).read_text(encoding='utf-8-sig', errors='replace')
blocks = re.split(r'\n\s*\n', text.strip())
cues = []
for b in blocks:
lines = [l for l in b.split('\n') if l.strip()]
if len(lines) < 2:
continue
# 第一行可能是序号
m = re.match(r'^(\d+)$', lines[0].strip())
idx = int(m.group(1)) if m else None
time_line = lines[1] if m else lines[0]
tm = re.match(r'([\d:,.]+)\s*-->\s*([\d:,.]+)', time_line)
if not tm:
continue
start, end = parse_time(tm.group(1)), parse_time(tm.group(2))
text_lines = lines[(2 if m else 1):]
cues.append(Cue(idx or len(cues) + 1, start, end, text_lines))
return cues
def check_srt(path: str, cfg: dict) -> list:
"""返回问题列表。"""
cues = parse_srt(path)
issues = []
for i, c in enumerate(cues):
dur = c.end - c.start
text = ''.join(c.lines)
n_chars = len(text.replace('\n', ''))
cps = n_chars / dur if dur > 0 else 999
# 时长
if dur < cfg['min_duration']:
issues.append({'file': path, 'cue': c.index, 'type': '过短',
'detail': f'{dur:.2f}s < {cfg["min_duration"]}s',
'level': 'fail'})
if dur > cfg['max_duration']:
issues.append({'file': path, 'cue': c.index, 'type': '过长',
'detail': f'{dur:.2f}s > {cfg["max_duration"]}s',
'level': 'warn'})
# 阅读速度
if cps > cfg['max_cps_cjk']:
issues.append({'file': path, 'cue': c.index, 'type': '阅读过快',
'detail': f'{cps:.1f} 字/秒', 'level': 'fail'})
# 行数
if len(c.lines) > cfg['max_lines']:
issues.append({'file': path, 'cue': c.index, 'type': '行数过多',
'detail': f'{len(c.lines)} 行', 'level': 'warn'})
# 每行长度
for ln in c.lines:
if len(ln) > cfg['max_chars_per_line']:
issues.append({'file': path, 'cue': c.index, 'type': '单行过长',
'detail': f'{len(ln)} 字', 'level': 'fail'})
# 与下一行的间隔 / 重叠
if i + 1 < len(cues):
nxt = cues[i + 1]
gap = nxt.start - c.end
if gap < 0:
issues.append({'file': path, 'cue': c.index, 'type': '重叠',
'detail': f'与下一条重叠 {abs(gap):.2f}s', 'level': 'fail'})
elif gap < cfg['min_gap']:
issues.append({'file': path, 'cue': c.index, 'type': '间隔过小',
'detail': f'{gap:.3f}s', 'level': 'warn'})
return issues
DEFAULT_CFG = {
'min_duration': 0.7,
'max_duration': 7.0,
'min_gap': 0.08,
'max_cps_cjk': 9.0,
'max_lines': 2,
'max_chars_per_line': 20,
}
if __name__ == '__main__':
import sys
files = sys.argv[1:] or sorted(Path('.').glob('*.srt'))
all_issues = []
for f in files:
all_issues += check_srt(str(f), DEFAULT_CFG)
with open('subtitle_qc.csv', 'w', newline='', encoding='utf-8') as fp:
w = csv.DictWriter(fp, fieldnames=['file', 'cue', 'type', 'detail', 'level'])
w.writeheader()
w.writerows(all_issues)
fails = [i for i in all_issues if i['level'] == 'fail']
print(f'共 {len(all_issues)} 个问题,其中必须修 {len(fails)} 个')
for i in fails[:20]:
print(f" {Path(i['file']).name} #{i['cue']} {i['type']}: {i['detail']}")
sys.exit(1 if fails else 0)
注意 utf-8-sig:读 SRT 用 utf-8-sig 自动去掉 BOM(下一节详述)。
五、编码检查:乱码的根源
SRT 文件必须是 UTF-8(带不带 BOM 都行,但推荐带 BOM——很多 Windows 工具和播放器靠 BOM 判断编码)。
检查:
def check_encoding(path: str) -> dict:
raw = Path(path).read_bytes()
has_bom = raw.startswith(b'\xef\xbb\xbf')
try:
raw.decode('utf-8')
valid_utf8 = True
except UnicodeDecodeError:
valid_utf8 = False
result = {'has_bom': has_bom, 'valid_utf8': valid_utf8}
if not valid_utf8:
# 尝试判断是不是 GBK
try:
raw.decode('gbk')
result['maybe'] = 'gbk'
except UnicodeDecodeError:
result['maybe'] = 'unknown'
return result
如果是 GBK → 转成 UTF-8(带 BOM):
def convert_to_utf8(src, dst):
raw = Path(src).read_bytes()
for enc in ('utf-8-sig', 'gbk', 'gb18030', 'big5'):
try:
text = raw.decode(enc)
break
except UnicodeDecodeError:
continue
else:
raise ValueError('无法识别编码')
Path(dst).write_text(text, encoding='utf-8-sig')
为什么推荐 BOM:
很多播放器/工具(尤其是 Windows 上的)在没有 BOM 时会按系统默认编码(GBK)去解读 UTF-8 文件,结果就是乱码。加了 BOM,它们就能正确识别。
代价:某些严格的 Linux 工具会认为 BOM 是内容的一部分(在第一行开头多一个不可见字符)。权衡下来,加 BOM 的好处更大(我们面向的是播放器和大众工具)。
六、系统性偏移检测
这个检查能抓出"整条字幕早/晚"的问题。
原理:把字幕的时间轴和"语音实际出现的时间"对比。语音出现时间可以用:
- Whisper 等 ASR 的时间戳(如果字幕本来就是 ASR 生成的,那就用另一个模型/参数再跑一次做交叉验证);
- 强制对齐工具(比如 WhisperX 的对齐);
- 或者简单的音频能量检测:检测语音段的起止,和字幕对比。
简化实现(用音频能量检测语音段):
import numpy as np
import subprocess
def extract_audio(video: str, sr: int = 16000) -> np.ndarray:
p = subprocess.run(
['ffmpeg', '-v', 'error', '-i', video, '-vn', '-ac', '1',
'-ar', str(sr), '-f', 's16le', '-'], capture_output=True, check=True)
return np.frombuffer(p.stdout, dtype=np.int16).astype(np.float32) / 32768.0
def speech_segments(audio, sr=16000, win=0.02, thresh=0.02, min_len=0.3):
"""用短时能量检测语音段(粗糙,用于检测系统性偏移)。"""
w = int(sr * win)
energy = np.array([np.sqrt((audio[i:i + w] ** 2).mean())
for i in range(0, len(audio) - w, w)])
loud = energy > thresh
segs = []
start = None
for i, v in enumerate(loud):
t = i * win
if v and start is None:
start = t
elif not v and start is not None:
if t - start >= min_len:
segs.append((start, t))
start = None
return segs
def detect_offset(sub_cues, speech_segs):
"""计算字幕起点与最近语音起点的偏移中位数。"""
offsets = []
for c in sub_cues:
# 找最近的语音段起点
best = None
for s, e in speech_segs:
d = s - c.start
if best is None or abs(d) < abs(best):
best = d
if s > c.start + 5:
break
if best is not None and abs(best) < 3.0: # 只考虑 3 秒内的
offsets.append(best)
return float(np.median(offsets)) if offsets else None
如果中位数偏移超过阈值(比如 0.5 秒)→ 整条字幕有系统性偏移,需要整体平移。
更准确的做法是用 WhisperX 的强制对齐(alignment)——它能给出每个词的精确时间戳,和字幕对比非常准。前面项目里那篇"WhisperX 强制对齐"的文章讲过具体用法。
注意:如果字幕本来就是 ASR 生成的,用同一个 ASR 再验证一次没意义(同样的偏差会重复)。要用不同的方法交叉验证。
七、ASS 的额外检查
ASS 除了上面的检查,还要看:
| 检查项 | 说明 |
|---|---|
| 样式是否存在 | Dialogue 里引用的 Style 必须在 [V4+ Styles] 里定义 |
| 分辨率匹配 | PlayResX/Y 要和视频分辨率一致(否则位置错乱) |
| 字体是否存在 | 指定的字体在目标机器上有没有(前面 ASS 字体那篇讲过) |
| 特效标签闭合 | {\pos(...)} 这类标签有没有正确闭合 |
| 重叠(同层) | 同 Layer 且时间重叠的事件 |
| 默认样式 | 有没有 Default 样式(很多播放器依赖它) |
def check_ass(path: str) -> list:
text = Path(path).read_text(encoding='utf-8-sig')
issues = []
# PlayRes
m = re.search(r'PlayResX:\s*(\d+)', text)
m2 = re.search(r'PlayResY:\s*(\d+)', text)
playres = (int(m.group(1)), int(m2.group(1))) if m and m2 else None
# 样式定义
styles = set(re.findall(r'^Style:\s*([^,]+),', text, re.M))
if styles and 'Default' not in styles:
issues.append({'type': '缺少 Default 样式', 'level': 'warn'})
# Dialogue 引用的样式
used = set(re.findall(r'^Dialogue:\s*[^,]+,([^,]+),', text, re.M))
for s in used - styles:
issues.append({'type': f'引用了未定义的样式: {s}', 'level': 'fail'})
# 字体
fonts = set(re.findall(r'^Style:.*?,([^,]+),\d+', text, re.M))
for f in fonts:
issues.append({'type': f'字体(需人工确认可用): {f}', 'level': 'info'})
return issues
八、接入交付流程
和视频验收脚本合并(前面那篇交付验收的文章):
def accept_delivery(video, subtitle, video_spec):
issues = accept(video, video_spec) # 视频检查
if subtitle:
issues += check_srt(subtitle, SUBTITLE_CFG) # 字幕检查
enc = check_encoding(subtitle)
if not enc['valid_utf8']:
issues.append({'file': subtitle, 'cue': '-', 'type': '非 UTF-8',
'detail': enc.get('maybe', ''), 'level': 'fail'})
return issues
退出码接入 CI / 交付流程:有问题就不让走下一步。
报告给客户看的是一份 CSV/HTML:每个问题有文件、行号、类型、具体数值——客户能直接定位到字幕文件里改。
九、能自动修什么
| 问题 | 能自动修吗 | 怎么修 |
|---|---|---|
| 显示过短 | ✅ | 延长到最短时长(但如果下一行紧挨着,要同时推后下一行) |
| 显示过长 | ⚠️ | 可以按字数拆成两行(但要找合适的断句点,容易拆错) |
| 间隔过小 | ✅ | 把前一条的结束时间往前挪一点 |
| 重叠 | ✅ | 让前一条在下一行开始时结束 |
| 系统性偏移 | ✅ | 整体平移 |
| 编码错误 | ✅ | 转 UTF-8 |
| 单行过长 | ❌ | 需要重新断句(内容问题) |
| 阅读过快 | ❌ | 要么减字要么加时间(内容决策) |
| 断句不合理 | ❌ | 人工 |
我的原则:只自动修"技术性"问题,不碰"内容性"问题。
前五种是机械的、不会改变语义的;后面几种需要理解内容,自动改会改错,宁可列出来让人改。
自动修完一定要再跑一遍检查,确认没有引入新问题(比如延长 A 行导致 A 和 B 重叠)。
十、坑清单
- 用 UTF-8 读带 BOM 的文件 → 第一行多一个
\ufeff,解析出错。用utf-8-sig。 - SRT 和 ASS 的时间分隔符搞混 → 逗号 vs 点号。分别处理。
- 用英文的阈值检查中文 → 中文的字数和阅读速度标准不同。分开配置。
- 把换行符算进字符数 → 要先去掉
\n。 - 不检查重叠 → 播放时两行叠在一起。
- 忽略了"间隔过小" → 视觉上像连在一起(虽然技术上是两条)。
- 系统性偏移没检测 → 整条字幕都早/晚,逐行看每行的误差都在阈值内,但整体是错的。必须看中位数。
- 用同一个 ASR 验证同一个 ASR 的输出 → 同样的偏差会重复,验证不出来。用不同方法交叉验证。
- 自动修了内容性问题 → 把断句改错了。只修技术性问题。
- 修完没有复检 → 引入新的重叠。
- ASS 的样式引用了不存在的样式 → 播放器用默认样式(可能很难看)或者不显示。
- PlayRes 和视频分辨率不符 → 位置错乱(竖屏尤其明显)。
- 没检查字体可用性 → 在别的机器上变成宋体(前面那篇讲过)。
- 字幕里残留了 ASR 的幻觉内容(重复的句子、语气词的循环)→ 需要额外检测(重复行检测)。
- 只检查了 SRT 没检查烧录后的效果 → 交付前要抽几段看实际画面(字号、位置、是否被裁切)。
最后说说检查脚本带来的改变。
它把"字幕质量"从"主观感受"变成了"可量化的清单"。
以前我说"这批字幕质量不太好",客户会问"哪里不好?"——我只能举几个例子,没有完整的清单。现在我可以给一份 CSV:"共 47 个问题,其中 23 个必须修:12 行显示过短、6 行单行过长、3 处重叠、2 个文件编码错误"。
这个转变的价值不只是效率,更是沟通的确定性:
- 客户知道具体要改什么;
- 我们能证明改完了(复检报告);
- 双方对"合格"的标准是一致的(阈值写死在配置里)。
还有一点:阈值本身是可争议的,但"有阈值"这件事不是。
中文每行 16 字还是 20 字、最短 0.7 秒还是 0.83 秒——这些数字不同客户有不同偏好。但只要事先约定并写进配置,就不会在交付时扯皮。所以我会让客户确认阈值配置(跟前面视频验收规格是同一套做法)。
把标准外化成配置文件,让机器去执行——这是我们在好几件事上反复验证过的有效做法。 字幕检查只是又一个例子。