提示

返回博客列表

字幕做完别急着交付:时间轴、重叠、超长行与乱码的自动检查

我们出了一批带字幕的视频,交付后客户退回来说字幕有问题。我一看,问题五花八门:

  • 有一行只显示 0.4 秒——根本来不及看;
  • 有两行时间重叠——屏幕上同时显示两行;
  • 有几行超出屏幕宽度——在小屏幕上被截断;
  • 有两个文件乱码——编码问题;
  • 还有一整条字幕比音频早了 1.5 秒——时间轴系统性偏移。

这些问题人工看很难发现(尤其是"0.4 秒"这种),但它们会让观众觉得"这字幕做得不专业"。

后来我写了个检查脚本,交付前自动跑一遍,输出问题清单。这篇写完整的检查项、行业参考阈值和脚本实现。

TL;DR:字幕验收的检查项:最短显示时长(≥ 0.7~0.83 秒)、最长显示时长(≤ 7 秒)、阅读速度(中文 ≤ 9 字/秒,英文 ≤ 20 字符/秒)、每行字符数(中文 ≤ 16~20,英文 ≤ 42)、最多两行、行间隔(≥ 2 帧)、重叠检测、编码检查(必须是 UTF-8)、系统性偏移检测(与音频对齐对比)。脚本解析 SRT/ASS 后逐项检查,输出 CSV 报告,用退出码接入交付流程。能自动修的(过短延长、过长拆分)可以自动处理,内容问题必须人工。

目录

一、字幕质量的九类问题

问题 表现 严重度
显示过短 一闪而过,看不清 高
显示过长 停留太久,与画面脱节 中
阅读速度过快 字数多但时间短 高
行数过多 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 重叠)。

十、坑清单

  1. 用 UTF-8 读带 BOM 的文件 → 第一行多一个 \ufeff,解析出错。用 utf-8-sig。
  2. SRT 和 ASS 的时间分隔符搞混 → 逗号 vs 点号。分别处理。
  3. 用英文的阈值检查中文 → 中文的字数和阅读速度标准不同。分开配置。
  4. 把换行符算进字符数 → 要先去掉 \n。
  5. 不检查重叠 → 播放时两行叠在一起。
  6. 忽略了"间隔过小" → 视觉上像连在一起(虽然技术上是两条)。
  7. 系统性偏移没检测 → 整条字幕都早/晚,逐行看每行的误差都在阈值内,但整体是错的。必须看中位数。
  8. 用同一个 ASR 验证同一个 ASR 的输出 → 同样的偏差会重复,验证不出来。用不同方法交叉验证。
  9. 自动修了内容性问题 → 把断句改错了。只修技术性问题。
  10. 修完没有复检 → 引入新的重叠。
  11. ASS 的样式引用了不存在的样式 → 播放器用默认样式(可能很难看)或者不显示。
  12. PlayRes 和视频分辨率不符 → 位置错乱(竖屏尤其明显)。
  13. 没检查字体可用性 → 在别的机器上变成宋体(前面那篇讲过)。
  14. 字幕里残留了 ASR 的幻觉内容(重复的句子、语气词的循环)→ 需要额外检测(重复行检测)。
  15. 只检查了 SRT 没检查烧录后的效果 → 交付前要抽几段看实际画面(字号、位置、是否被裁切)。

最后说说检查脚本带来的改变。

它把"字幕质量"从"主观感受"变成了"可量化的清单"。

以前我说"这批字幕质量不太好",客户会问"哪里不好?"——我只能举几个例子,没有完整的清单。现在我可以给一份 CSV:"共 47 个问题,其中 23 个必须修:12 行显示过短、6 行单行过长、3 处重叠、2 个文件编码错误"。

这个转变的价值不只是效率,更是沟通的确定性:

  • 客户知道具体要改什么;
  • 我们能证明改完了(复检报告);
  • 双方对"合格"的标准是一致的(阈值写死在配置里)。

还有一点:阈值本身是可争议的,但"有阈值"这件事不是。

中文每行 16 字还是 20 字、最短 0.7 秒还是 0.83 秒——这些数字不同客户有不同偏好。但只要事先约定并写进配置,就不会在交付时扯皮。所以我会让客户确认阈值配置(跟前面视频验收规格是同一套做法)。

把标准外化成配置文件,让机器去执行——这是我们在好几件事上反复验证过的有效做法。 字幕检查只是又一个例子。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部