DEV Community

Cover image for Write an SRT Linter That Checks Timing Against the Media File
Joey
Joey

Posted on

Write an SRT Linter That Checks Timing Against the Media File

Shorten an edit and an old subtitle file can remain perfectly readable while its final cue now ends after the video.

I want that caught before playback. The check needs the exported media's duration, not the duration I remember from the timeline.

Keep the comparison in milliseconds

This linter reads ordinary numbered UTF-8 SRT blocks, including files with a BOM. It checks timestamp syntax, cue order, positive duration, and the end of the media. Overlapping cues and a configurable reading-speed threshold produce warnings.

It uses ffprobe's container duration as the boundary. This assumes the subtitle file belongs to that export and starts on the same zero-based timeline.

Save this as srt_lint.py. Python's standard library is enough; ffprobe must be on your PATH.

import argparse
from decimal import Decimal, InvalidOperation
import json
import math
from pathlib import Path
import re
import subprocess

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


def milliseconds(stamp):
    hours, minutes, rest = stamp.split(":")
    seconds, millis = rest.split(",")
    return ((int(hours) * 60 + int(minutes)) * 60 + int(seconds)) * 1000 + int(millis)


def duration_ms(media):
    result = subprocess.run([
        "ffprobe", "-v", "error", "-show_entries", "format=duration",
        "-of", "json", str(Path(media).resolve()),
    ], check=True, capture_output=True, text=True)
    duration = Decimal(json.loads(result.stdout)["format"]["duration"])
    if not duration.is_finite() or duration <= 0:
        raise ValueError("media duration must be positive and finite")
    return int(duration * 1000)


def lint(text, limit_ms, cps=20):
    if not math.isfinite(cps) or cps <= 0:
        raise ValueError("cps must be positive and finite")
    blocks, block = [], []
    for line, value in enumerate(text.splitlines() + [""], 1):
        if value.strip():
            block.append((line, value))
        elif block:
            blocks.append(block)
            block = []
    issues, previous_start, latest_end = [], None, 0
    if not blocks:
        return [("ERROR", 1, "no cues found")]
    for expected, block in enumerate(blocks, 1):
        line = block[0][0]
        if block[0][1] != str(expected):
            issues.append(("ERROR", line, f"expected cue index {expected}"))
        if len(block) < 3:
            issues.append(("ERROR", line, "cue needs index, timing and text"))
            continue
        line = block[1][0]
        match = TIMING.fullmatch(block[1][1])
        if not match:
            issues.append(("ERROR", line, "invalid timestamp line"))
            continue
        start, end = map(milliseconds, match.groups())
        if end <= start:
            issues.append(("ERROR", line, "end must be after start"))
        if end > limit_ms:
            issues.append(("ERROR", line, "cue ends after media"))
        if previous_start is not None:
            if start < previous_start:
                issues.append(("ERROR", line, "cue starts out of order"))
            elif start < latest_end:
                issues.append(("WARN", line, "overlaps an earlier cue"))
        previous_start = start
        if end > start:
            latest_end = max(latest_end, end)
        visible = re.sub(r"<[^>]*>", "", "".join(v for _, v in block[2:]))
        if end > start:
            rate = len(visible) * 1000 / (end - start)
            if rate > cps:
                issues.append(("WARN", line, f"{rate:.1f} cps exceeds {cps:g}"))
    return issues


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("subtitles")
    parser.add_argument("media")
    parser.add_argument("--cps", type=float, default=20)
    args = parser.parse_args()
    try:
        text = Path(args.subtitles).read_text(encoding="utf-8-sig")
        issues = lint(text, duration_ms(args.media), args.cps)
    except (OSError, ValueError, KeyError, InvalidOperation,
            subprocess.CalledProcessError) as error:
        parser.exit(2, f"error: {error}\n")
    for level, line, message in issues:
        print(f"{level} line {line}: {message}")
    if not issues:
        print("OK: no issues found")
    raise SystemExit(1 if any(level == "ERROR" for level, _, _ in issues) else 0)
Enter fullscreen mode Exit fullscreen mode

Catch an overrun

For a media file exactly three seconds long, save this as late.srt:

1
00:00:00,000 --> 00:00:01,000
Hello.

2
00:00:02,500 --> 00:00:03,500
Goodbye.
Enter fullscreen mode Exit fullscreen mode

Then run:

python srt_lint.py late.srt three_seconds.mp4
Enter fullscreen mode Exit fullscreen mode

Output:

ERROR line 6: cue ends after media
Enter fullscreen mode Exit fullscreen mode

The line number points to the timestamp that needs attention. The process exits with 1 for validation errors, 2 for a read or probe failure, and 0 when there are no errors. Warnings alone keep exit code 0.

Leave reading speed configurable

python srt_lint.py captions.srt export.mp4 --cps 18
Enter fullscreen mode Exit fullscreen mode

That threshold is an editorial setting, not a universal subtitle standard. The count includes spaces and Unicode code points, omits line breaks, and strips simple markup tags. It does not measure spoken words or listening accuracy.

I keep overlaps as warnings because simultaneous speakers may be intentional. For an unusually long cue containing shorter cues, the linter retains the furthest earlier end time, so later overlaps are not missed.

The local checks covered valid Unicode text, malformed timestamps, zero-length cues, reversed order, nested overlaps, and a cue beyond a real three-second export. Once those mechanical problems are cleared, I can spend the playback pass checking whether the captions actually follow the voice.

References: SubRip structure and ffprobe documentation.

Top comments (0)