AIで作った字幕テンプレートを売る前に、SRTの時刻重なりを止める

動画教材や短い解説動画に添える字幕テンプレートは、小さなテキストファイルです。だからこそ、AIに下書きを頼んだあと「開けるから大丈夫」と思い込みやすいです。今回は販売用の字幕テンプレートを想定した合成見本で、SRTの時刻が重なったときに納品前で止められるか試しました。
SRTは、連番、開始と終了の時刻、字幕文を空行で区切って並べる形式です。字幕を人が読むだけなら、2番目の開始が1番目の終了より早くても気づかない場合があります。見落としやすいのは、時刻の重なり。動画編集ソフトに読み込んで確かめる方法もありますが、配布するファイルを増やす前に、テキストの段階で止められるほうが扱いやすいと考えました。
まずは、重なりを見逃す入力を置く
合成見本では、2つ目の字幕を1番目が消える100ミリ秒前から始めます。文章として読むと不自然ではありません。でも時刻は重なっています。
1
00:00:00,000 --> 00:00:02,000
はじめの字幕です。
2
00:00:01,900 --> 00:00:04,000
重なった字幕です。
私が最初に迷ったのは、時刻を「形式どおりか」だけ見ればよいのか、それとも前後関係まで見るべきかでした。デジタル商品として渡すなら、購入者が自分の動画へ読み込む前に、少なくとも隣の字幕同士の衝突は作り手側で見つけたいです。そこで、連番、時刻の書式、終了が開始より後か、前の終了より後に始まるかの4点だけを検査します。
小さな検査に絞る
次のコードを$TMPDIR/srt-sample/srt_gate.pyとして保存しました。時刻をミリ秒へ換算し、前の字幕の終了時刻を持ちながら次を読みます。字幕の自然さや読みやすさは判定しません。そこまで混ぜると、数値で止める検査の役割がぼやけるからです。
srt_gate.py
from __future__ import annotations
import re
import sys
from pathlib import Path
TIMECODE = re.compile(
r"^(?P<sh>\d{2}):(?P<sm>\d{2}):(?P<ss>\d{2}),(?P<sms>\d{3}) --> "
r"(?P<eh>\d{2}):(?P<em>\d{2}):(?P<es>\d{2}),(?P<ems>\d{3})$"
)
def to_ms(match: re.Match[str], prefix: str) -> int:
return (
int(match[f"{prefix}h"]) * 3_600_000
+ int(match[f"{prefix}m"]) * 60_000
+ int(match[f"{prefix}s"]) * 1_000
+ int(match[f"{prefix}ms"])
)
def main(path: Path) -> int:
blocks = [block for block in path.read_text(encoding="utf-8").strip().split("\n\n") if block]
previous_end = -1
previous_number = 0
for position, block in enumerate(blocks, start=1):
lines = block.splitlines()
if len(lines) < 3 or not lines[0].isdigit():
print(f"NG cue {position}: sequence number or subtitle text is missing")
return 1
number = int(lines[0])
if number != previous_number + 1:
print(f"NG cue {position}: expected sequence {previous_number + 1}, got {number}")
return 1
match = TIMECODE.fullmatch(lines[1])
if not match:
print(f"NG cue {number}: invalid timecode {lines[1]!r}")
return 1
start, end = to_ms(match, "s"), to_ms(match, "e")
if end <= start:
print(f"NG cue {number}: end must be after start")
return 1
if start < previous_end:
print(f"NG cue {number}: starts before the prior cue ends")
return 1
previous_end, previous_number = end, number
print(f"OK cues={len(blocks)} last_end_ms={previous_end}")
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: python3 srt_gate.py FILE.srt", file=sys.stderr)
raise SystemExit(2)
raise SystemExit(main(Path(sys.argv[1])))
合格用、重なり用、時刻の区切りが違う用の3ファイルを作ります。SRTの時刻は時:分:秒,ミリ秒で、開始と終了の間には-->を置きます。
mkdir -p "$TMPDIR/srt-sample"
cd "$TMPDIR/srt-sample"
cat > valid.srt <<'EOF'
1
00:00:00,000 --> 00:00:02,000
はじめの字幕です。
2
00:00:02,200 --> 00:00:04,000
次の字幕です。
EOF
cat > overlap.srt <<'EOF'
1
00:00:00,000 --> 00:00:02,000
はじめの字幕です。
2
00:00:01,900 --> 00:00:04,000
重なった字幕です。
EOF
cat > bad-timecode.srt <<'EOF'
1
00:00:00,000 --> 00:00:02.000
区切りが違う字幕です。
EOF
正常な字幕と、壊れた字幕を同じ条件で流す
まず、時刻が空いているvalid.srtです。最後の終了時刻は4000ミリ秒で、終了コードは0でした。
python3 srt_gate.py valid.srt; printf 'exit=%s\n' "$?"
OK cues=2 last_end_ms=4000
exit=0
次は重なりです。2番目の開始が前の終了より早いので、検査は1を返しました。この1行が出たら、AIへ修正を頼む場合でも、開始時刻を2秒以降へ直すという条件を渡せます。
python3 srt_gate.py overlap.srt; printf 'exit=%s\n' "$?"
NG cue 2: starts before the prior cue ends
exit=1
時刻の書式も、見た目だけでは紛れます。bad-timecode.srtは終了時刻のミリ秒をピリオドで書きました。SRTで使うカンマではないので、ここも止まりました。
python3 srt_gate.py bad-timecode.srt; printf 'exit=%s\n' "$?"
NG cue 1: invalid timecode '00:00:00,000 --> 00:00:02.000'
exit=1
引数を渡さない場合も、何も調べずに成功にはしません。配布前の手順に入れるなら、対象ファイルを渡し忘れた操作を区別できる必要があります。
python3 srt_gate.py; printf 'exit=%s\n' "$?"
usage: python3 srt_gate.py FILE.srt
exit=2
AIへ返す指示を、曖昧な感想にしない
重なりを見つけたあとに「字幕を直してください」だけを返すと、別の場所まで変わることがあります。検査の出力をそのまま渡します。次の条件を短く添えれば、修正後に確認する点が残せます。
cue 2の開始時刻がcue 1の終了時刻より前です。
SRTの連番と本文は変えず、開始時刻を終了時刻以降へ直してください。
修正後にsrt_gate.pyを再実行し、exit=0を確認してください。
この検査は字幕を販売できる品質だと保証しません。行の長さ、言葉の自然さ、映像と話者の同期は、動画を再生して人が見る領域です。それでも、時刻の重なりや書式の誤りを先に落とせば、購入者へ渡す前に見るべき場所を減らせます。
デジタル商品では、AIの出力をそのまま納品物にせず、失敗が数字で出る小さな関門を置くことが大切です。字幕なら、まずexit=0までです。
出典
- Library of Congress, SubRip Subtitle format (SRT): SRTが時刻、字幕文、空行の区切りからなることと、時刻の表記を確認しました。


