Short answer: SRT and VTT look almost the same, but they differ in three small ways that break a simple parser. This post gives you one regex and three short functions that read both formats, join the cues into paragraphs, and turn each paragraph into a link that opens the video at that second. It uses only the Python standard library.
Why a quick parser breaks
A caption file is a list of cues. Each cue has a start time, an end time and some text. Here are the differences that cause trouble:
- SRT writes milliseconds with a comma (00:00:01,000). VTT uses a dot (00:00:01.000).
- SRT always writes the hours. VTT can leave them out, so you can see 00:01.000 for one second.
- VTT starts with a WEBVTT line, and a cue can have settings after the times, like align:start.
- Text can run over two lines, and it can have tags like italic markers.
The regex below accepts both the comma and the dot, and treats the hours as optional.
The sample files
I made two small files for the test. The SRT one has a cue after an 11 second pause, and one cue at the 1 hour mark. The VTT one has no hours in its first cues, and it has a setting after one timestamp.
SRT = """1
00:00:01,000 --> 00:00:04,200
Welcome back. Today we talk about
writing things down.
2
00:00:04,200 --> 00:00:09,800
If a decision is made in a call,
it goes into a shared document.
3
00:00:21,500 --> 00:00:26,000
<i>Second topic:</i> hiring.
4
00:00:26,000 --> 00:00:31,400
We give a paid two day task
that looks like real work.
5
01:02:03,450 --> 01:02:08,000
Thanks for watching.
"""
VTT = """WEBVTT
00:01.000 --> 00:04.200
Welcome back. Today we talk about
writing things down.
00:04.200 --> 00:09.800 align:start
If a decision is made in a call,
it goes into a shared document.
01:02:03.450 --> 01:02:08.000
Thanks for watching.
"""
Step 1: Read the cues
TIME = re.compile(
r"(?:(\d+):)?(\d{1,2}):(\d{2})[,.](\d{3})\s*-->\s*(?:(\d+):)?(\d{1,2}):(\d{2})[,.](\d{3})"
)
def to_seconds(h, m, s, ms):
return int(h or 0) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
def parse_cues(text):
"""Works for SRT and VTT. Returns a list of (start_seconds, end_seconds, text)."""
cues = []
for block in re.split(r"\n\s*\n", text.replace("\r\n", "\n").strip()):
lines = block.split("\n")
for n, line in enumerate(lines):
m = TIME.search(line)
if m:
g = m.groups()
start = to_seconds(*g[0:4])
end = to_seconds(*g[4:8])
body = " ".join(lines[n + 1:])
body = re.sub(r"<[^>]+>", "", body) # drop <i>, <c>, <v Name> tags
body = re.sub(r"\s+", " ", body).strip()
if body:
cues.append((start, end, body))
break
return cues
The file is split on blank lines, so each block is one cue. In each block the code looks for the line with the arrow, and the text is every line after it. The tags are removed with a second regex, and the line breaks are joined with a space. A block with no arrow, like the WEBVTT header, is skipped.
Step 2: Join cues into paragraphs
Captions come in pieces of two or three seconds. That is too small for a search result or a table of contents. So I join the cues until there is a pause longer than 5 seconds, or until the paragraph gets long.
def merge_into_paragraphs(cues, max_gap=5.0, max_chars=300):
"""Start a new paragraph after a pause longer than max_gap seconds,
or when the paragraph gets longer than max_chars."""
paragraphs = []
for start, end, body in cues:
if paragraphs:
p = paragraphs[-1]
if start - p["end"] <= max_gap and len(p["text"]) + len(body) < max_chars:
p["text"] += " " + body
p["end"] = end
continue
paragraphs.append({"start": start, "end": end, "text": body})
return paragraphs
The numbers 5 seconds and 300 characters are my choices for this demo. Change them for your own videos.
Step 3: Make the links
def clock(seconds):
s = int(seconds)
return f"{s // 3600:02d}:{s % 3600 // 60:02d}:{s % 60:02d}"
def link(video_id, seconds):
return f"https://youtu.be/{video_id}?t={int(seconds)}"
A YouTube link with t=3723 opens the video at 1 hour, 2 minutes and 3 seconds. The function drops the milliseconds, because the t value is in whole seconds.
The real output
I ran the script on both sample files. VIDEO_ID is a placeholder.
--- SRT: 5 cues
[00:00:01] https://youtu.be/VIDEO_ID?t=1
Welcome back. Today we talk about writing things down. If a decision is made in a call, it goes into a shared document.
[00:00:21] https://youtu.be/VIDEO_ID?t=21
Second topic: hiring. We give a paid two day task that looks like real work.
[01:02:03] https://youtu.be/VIDEO_ID?t=3723
Thanks for watching.
--- VTT: 3 cues
[00:00:01] https://youtu.be/VIDEO_ID?t=1
Welcome back. Today we talk about writing things down. If a decision is made in a call, it goes into a shared document.
[01:02:03] https://youtu.be/VIDEO_ID?t=3723
Thanks for watching.
Both formats give the same first paragraph. The first two cues are joined in both files, because there is no gap between them. In the SRT file, the third cue starts after an 11 second pause, so it opens a new paragraph, and the fourth cue joins it. The cue at the 1 hour mark stands alone in both files, and the VTT time 01:02:03.450 gives the same link as the SRT time 01:02:03,450.
What this does not handle
- Some auto-generated caption files repeat the same words from one cue to the next. This script keeps the repeats.
- Word-level timing inside one cue is not read.
- I tested only the two sample files above. Run it on your own files before you rely on it.
Where the captions can come from
If you own the video, you can download captions from the video platform. If you want them from a transcription API, check that it can return SRT or VTT. For example, the docs of the tool I build, Libraryminds, say its transcript endpoint takes format=srt or format=vtt. You can read them in the developer docs. I did not use it for this post, and the script works with any caption file.
The full script
Save this as captions.py and run python captions.py.
import re
SRT = """1
00:00:01,000 --> 00:00:04,200
Welcome back. Today we talk about
writing things down.
2
00:00:04,200 --> 00:00:09,800
If a decision is made in a call,
it goes into a shared document.
3
00:00:21,500 --> 00:00:26,000
<i>Second topic:</i> hiring.
4
00:00:26,000 --> 00:00:31,400
We give a paid two day task
that looks like real work.
5
01:02:03,450 --> 01:02:08,000
Thanks for watching.
"""
VTT = """WEBVTT
00:01.000 --> 00:04.200
Welcome back. Today we talk about
writing things down.
00:04.200 --> 00:09.800 align:start
If a decision is made in a call,
it goes into a shared document.
01:02:03.450 --> 01:02:08.000
Thanks for watching.
"""
TIME = re.compile(
r"(?:(\d+):)?(\d{1,2}):(\d{2})[,.](\d{3})\s*-->\s*(?:(\d+):)?(\d{1,2}):(\d{2})[,.](\d{3})"
)
def to_seconds(h, m, s, ms):
return int(h or 0) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
def parse_cues(text):
"""Works for SRT and VTT. Returns a list of (start_seconds, end_seconds, text)."""
cues = []
for block in re.split(r"\n\s*\n", text.replace("\r\n", "\n").strip()):
lines = block.split("\n")
for n, line in enumerate(lines):
m = TIME.search(line)
if m:
g = m.groups()
start = to_seconds(*g[0:4])
end = to_seconds(*g[4:8])
body = " ".join(lines[n + 1:])
body = re.sub(r"<[^>]+>", "", body) # drop <i>, <c>, <v Name> tags
body = re.sub(r"\s+", " ", body).strip()
if body:
cues.append((start, end, body))
break
return cues
def merge_into_paragraphs(cues, max_gap=5.0, max_chars=300):
"""Start a new paragraph after a pause longer than max_gap seconds,
or when the paragraph gets longer than max_chars."""
paragraphs = []
for start, end, body in cues:
if paragraphs:
p = paragraphs[-1]
if start - p["end"] <= max_gap and len(p["text"]) + len(body) < max_chars:
p["text"] += " " + body
p["end"] = end
continue
paragraphs.append({"start": start, "end": end, "text": body})
return paragraphs
def clock(seconds):
s = int(seconds)
return f"{s // 3600:02d}:{s % 3600 // 60:02d}:{s % 60:02d}"
def link(video_id, seconds):
return f"https://youtu.be/{video_id}?t={int(seconds)}"
if __name__ == "__main__":
for name, text in (("SRT", SRT), ("VTT", VTT)):
print(f"--- {name}: {len(parse_cues(text))} cues")
for p in merge_into_paragraphs(parse_cues(text)):
print(f"[{clock(p['start'])}] {link('VIDEO_ID', p['start'])}")
print(f" {p['text']}")
If you have a caption file that this parser reads wrongly, send me the shape of it in the comments, and I will add it to the list above.
Top comments (0)