DEV Community

Cover image for Catch a stale opening caption with a small SRT test in Python
Anlin
Anlin

Posted on

Catch a stale opening caption with a small SRT test in Python

The opening of a caption tutorial shows “glaze,” and so does the final playback. At least that’s what you want. If the ending still says “glass” after you fixed the opening, a quick search for “glaze” won’t catch it. And “glass” belongs in the middle of the file, where you demonstrate the original error.

Give the check two ranges, one for the opening and one for the ending. Ask for the corrected cue exactly once in each. Leave the demonstration outside both ranges so you can change that part of the tutorial without breaking the check.

Disclosure: AI assisted the drafting of this tutorial. The included script and fixture were run locally.

Set up the fixture

Use a plain UTF-8 SRT export. The following example is a small local fixture, independent of a video project. Save it as sample.srt.

1
00:00:00,300 --> 00:00:01,900
Glaze.

2
00:00:03,200 --> 00:00:05,100
Start with the raw clip.

3
00:00:19,200 --> 00:00:20,600
Glass.

4
00:00:42,400 --> 00:00:44,100
Glaze.
Enter fullscreen mode Exit fullscreen mode

In this fixture the opening copy is entirely inside the first two seconds. The final segment starts at 42 seconds. Those are test values. On an actual job, use the times of the cut you are handing off. A source timecode from the raw recording will miss the shifted edit.

Compare the text inside the correct windows

This script is dependency-free and runs on Python 3.9 or newer. It uses ordinary SRT numbering, blank lines, and comma-separated timestamps. It reads a UTF-8 BOM and supports multiple text lines within a cue. The expected phrase is compared to the whole cue after normalizing case and punctuation, so a caption containing additional words will fail the test. That choice is useful for a one-line fixture and may be too strict for a longer subtitle.

import argparse
from dataclasses import dataclass
from pathlib import Path
import re
import sys

TIMESTAMP = r"[0-9]{2,}:[0-5][0-9]:[0-5][0-9],[0-9]{3}"
CUE_TIMES = re.compile(rf"({TIMESTAMP})\s+-->\s+({TIMESTAMP})")


@dataclass(frozen=True)
class Cue:
    start: int
    end: int
    text: str


def milliseconds(timestamp):
    hrs, mins, secs, ms = map(int, re.split(r"[:,]", timestamp))
    return ((hrs * 60 + mins) * 60 + secs) * 1000 + ms


def read_cues(path):
    text = path.read_text(encoding="utf-8-sig").strip()
    if not text:
        raise ValueError("empty subtitle file")

    cues = []
    for block in re.split(r"\n[ \t]*\n+", text):
        lines = block.splitlines()
        if len(lines) < 3 or not lines[0].strip().isdecimal():
            raise ValueError("expected cue number, time range and text")
        match = CUE_TIMES.fullmatch(lines[1].strip())
        if match is None:
            raise ValueError(f"invalid time range for cue {lines[0]}")
        start, end = map(milliseconds, match.groups())
        if end <= start:
            raise ValueError(f"nonpositive duration for cue {lines[0]}")
        cues.append(Cue(start, end, " ".join(lines[2:])))
    return cues


def normalized(line):
    return " ".join(re.findall(r"[\w']+", line.casefold()))


def review(cues, expected, opening_end, closing_start):
    expected = normalized(expected)
    if not expected:
        raise ValueError("expected text must contain words")
    if not 0 < opening_end < closing_start:
        raise ValueError("use nonoverlapping opening and closing ranges")

    opening = [cue for cue in cues if 0 <= cue.start < cue.end <= opening_end]
    closing = [cue for cue in cues if closing_start <= cue.start]
    for label, candidates in (("opening", opening), ("closing", closing)):
        exact = [cue for cue in candidates if normalized(cue.text) == expected]
        if len(exact) != 1:
            print(f"CHECK {label}: {len(exact)} exact matches for '{expected}'")
            return False
    print(f"PASS opening and closing each contain one cue for '{expected}'")
    return True


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("srt", type=Path)
    parser.add_argument("expected")
    parser.add_argument("--opening-end", type=int, required=True, help="milliseconds")
    parser.add_argument("--closing-start", type=int, required=True, help="milliseconds")
    args = parser.parse_args()
    try:
        ok = review(read_cues(args.srt), args.expected, args.opening_end, args.closing_start)
    except (OSError, ValueError) as exc:
        print(f"ERROR {exc}", file=sys.stderr)
        return 2
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Run a passing and a failing example

python check_cues.py sample.srt "Glaze" --opening-end 2000 --closing-start 42000
Enter fullscreen mode Exit fullscreen mode

The fixture prints:

PASS opening and closing each contain one cue for 'glaze'
Enter fullscreen mode Exit fullscreen mode

Now duplicate the file as stale.srt and change only the last cue text to “Glass.” Repeat the command with stale.srt. It reports CHECK closing: 0 exact matches for 'glaze' and exits with code 1. The earlier wrong caption in the tutorial remains outside the checked ranges, so it never affects either test. A malformed cue exits with code 2. In a build script those codes can distinguish an expected review failure from a fixture that needs repair.

Check the export as well

After the script passes, open the actual export. It can still have the wrong SRT attached, the line can come in early, or the playback controls can cover the word. Play the opening and the end from the rendered media and listen across both boundaries. At phone size, inspect the lower area for any collision between sample captions, a tutorial label and the video player.

Some projects intentionally split the phrase across two cues. In that case set the expected text for each opening and closing cue or define a richer rule for adjacent cues. Avoid running a global substring search to compensate. It would hide the stale-copy failure this check exists to catch.

Top comments (0)