Converting SRT to WebVTT: the cases a one-line regex gets wrong
Most snippets for this conversion do two things: add a WEBVTT header and swap commas for dots. That works on a clean file. Subtitle files from editors, transcription tools and old archives are often not clean, and the fa
Most snippets for this conversion do two things: add a WEBVTT header and swap commas for dots. That works on a clean file. Subtitle files from editors, transcription tools and old archives are often not clean, and the failures are quiet. The player shows nothing, or shows a tag as text.
These are the cases a small browser converter has to handle, followed by a parser that passes all of them.
Commas in the dialogue
A global replace also rewrites the words:
1
00:00:01,000 --> 00:00:04,000
Well, that went badly, didn't it?
Only the timing line should change. So parse the file into cues first and format them again, instead of editing it as a string.
Line endings and the BOM
Windows tools write \r\n, so splitting on \n\n finds a single huge block. Many editors also put a byte order mark at the start of the file, and then a check like text.startsWith("WEBVTT") fails on a valid file. Normalize both before anything else:
const clean = text.replace(/^/, "").replace(/\r\n?/g, "\n");
Old encodings
Russian .srt files from older tools are often in Windows-1251. FileReader.readAsText() assumes UTF-8 and silently produces replacement characters. A strict decoder turns that silence into an error you can act on:
function decode(buffer) {
try {
return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
} catch {
return new TextDecoder("windows-1251").decode(buffer);
}
}
The fallback is a guess, so show the result to the user before saving it.
Timestamps without hours
WebVTT allows 00:01.200. SRT does not, so going from VTT to SRT has to expand the value to 00:00:01,200. Parse timestamps into milliseconds and the problem disappears, because the output format decides how to print them.
Things after the timestamp
A VTT timing line can carry cue settings:
00:01.200 --> 00:03.000 align:start line:0
SRT has no place for them. Keep them when the target is VTT and drop them when it is SRT.
Blocks that are not cues
WebVTT files can contain NOTE, STYLE and REGION blocks, and a cue may have an identifier on the line before its timing. Treat a block as a cue only if one of its lines is a timing line. Then the text is everything after that line, and the numbers in SRT need no special handling.
There is one more trap here. Some generators write WEBVTT with the first cue directly below it and no blank line, so the header check must not throw that block away.
Tags that SRT players print
<v Anna> and <c.yellow> are WebVTT syntax. Many SRT players print them as text. <i>, <b> and <u> usually work in both formats, so those can stay.
Shifting the times
A negative offset creates two cases. A cue that ends before zero is gone and should be removed. A cue that starts before zero and ends after it should start at zero. Report the removed count, or the user will not know that lines disappeared.
The parser
const TIMING = /^\s*(?:(\d{1,3}):)?(\d{2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d{1,3}):)?(\d{2}):(\d{2})[.,](\d{1,3})(.*)$/;
const toMs = (h, m, s, f) =>
(+(h || 0)) * 3600000 + +m * 60000 + +s * 1000 + +(f + "00").slice(0, 3);
function parse(text) {
const cues = [];
const clean = text.replace(/^/, "").replace(/\r\n?/g, "\n");
for (const block of clean.split(/\n{2,}/)) {
const lines = block.split("\n").filter((l) => l.trim());
const i = lines.findIndex((l) => TIMING.test(l));
if (i < 0) continue; // a header, NOTE, STYLE or REGION block
const m = TIMING.exec(lines[i]);
cues.push({
start: toMs(m[1], m[2], m[3], m[4]),
end: toMs(m[5], m[6], m[7], m[8]),
settings: m[9].trim(),
text: lines.slice(i + 1).join("\n"),
});
}
return cues;
}
const pad = (n, w = 2) => String(n).padStart(w, "0");
const stamp = (t, sep) =>
`${pad(Math.floor(t / 3600000))}:${pad(Math.floor(t / 60000) % 60)}:` +
`${pad(Math.floor(t / 1000) % 60)}${sep}${pad(t % 1000, 3)}`;
function toVtt(cues) {
const body = cues.map((c) =>
`${stamp(c.start, ".")} --> ${stamp(c.end, ".")}${c.settings ? " " + c.settings : ""}\n${c.text}`);
return "WEBVTT\n\n" + body.join("\n\n") + "\n";
}
function toSrt(cues) {
const strip = (t) => t.replace(/<\/?(?:v|c|lang)\b[^>]*>/g, "");
const body = cues.map((c, n) =>
`${n + 1}\n${stamp(c.start, ",")} --> ${stamp(c.end, ",")}\n${strip(c.text)}`);
return body.join("\n\n") + "\n";
}
This parser has one known limit. A blank line inside a cue's text starts a new block, so the rest of that cue is lost. The WebVTT spec does not allow blank lines inside a cue, but some SRT files have them, and handling them means tracking state line by line.
A test file worth keeping
Put these in one file and run every change against it: a comma in the dialogue, CRLF endings, a BOM, a NOTE block, a cue identifier, a timestamp without hours, cue settings, a voice tag, and a WEBVTT header with no blank line after it. Every case above fits in about twenty lines.
If you have a recording and no subtitles yet, AudioVText transcribes audio and video and exports both SRT and VTT. Russian is its primary language, and it supports 99 languages in total.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.