The most annoying subtitle bug is often a consistent one: every line is early by the same amount. Manually editing dozens of timestamps is risky, and converting between SRT, VTT, and ASS can introduce a second class of mistakes. I built the Subtitle Timecode Fixer & Format Converter around a simpler model: parse the timing into milliseconds, apply one offset, then render it back into the requested format.
This article is for a video editor, translator, or frontend developer who needs to repair a batch of subtitle cues without sending the file to a server. The takeaway is that a time shift is an arithmetic operation, but format conversion is a data-loss decision. The tool is a concrete browser implementation of both choices.
Detect the format before changing anything
The input textarea is watched with detectFormat, which looks for structural markers instead of trusting a filename:
function detectFormat() {
const text = inputText.value.trim();
if (!text) { detectedFormat.value = ""; return; }
if (/^WEBVTT/m.test(text)) {
detectedFormat.value = "VTT";
} else if (/^[Ss]cript [Ii]nfo|^\[V4\+[Ss]tyles\]|^Dialogue:/m.test(text)) {
detectedFormat.value = "ASS/SSA";
} else if (/^\d+\s*\n\d{2}:\d{2}:\d{2},\d{3}\s*-->/m.test(text)) {
detectedFormat.value = "SRT";
} else {
detectedFormat.value = "";
}
}
This is intentionally a format hint, not a full validator. A WebVTT header identifies VTT, a Dialogue: line is characteristic of ASS, and an SRT cue needs an index followed by a comma-separated timestamp range. If none match, processing stops with an unknown-format message rather than trying to “fix” arbitrary text.
Normalize timestamps to one unit
SRT and VTT have different separators, but the parser converts both into milliseconds. SRT splits hh:mm:ss,mmm, while VTT accepts either hh:mm:ss.mmm or the shorter mm:ss.mmm form:
function srtTimeToMs(ts) {
const [hms, ms] = ts.split(",");
const [h, m, s] = hms.split(":").map(Number);
return (h * 3600 + m * 60 + s) * 1000 + Number(ms);
}
function vttTimeToMs(ts) {
const parts = ts.split(":");
let h = 0, m = 0, s = 0;
if (parts.length === 3) {
[h, m] = [Number(parts[0]), Number(parts[1])];
s = parseFloat(parts[2]);
} else {
[m] = [Number(parts[0])];
s = parseFloat(parts[1]);
}
return Math.round((h * 3600 + m * 60 + s) * 1000);
}
Once every entry has start and end values in the same unit, the offset is easy to apply:
entries = entries.map(e => ({
...e,
start: Math.max(0, e.start + offset),
end: Math.max(0, e.end + offset),
}));
The Math.max(0, ...) guard is important. Advancing subtitles by a negative value should not create a negative timestamp that cannot be represented by the output formatter. A positive value delays both boundaries, preserving each cue’s duration.
Rendering is where formats stop being equivalent
SRT rendering renumbers entries and writes comma milliseconds. VTT adds a WEBVTT header and uses periods. ASS keeps the original file lines and replaces only the start and end fields on Dialogue: lines:
const assLines = text.split("\n");
let entryIdx = 0;
result = assLines.map(line => {
if (/^Dialogue:/.test(line) && entryIdx < entries.length) {
const e = entries[entryIdx++];
const fields = line.split(",");
fields[1] = msToAssTime(e.start);
fields[2] = msToAssTime(e.end);
return fields.join(",");
}
return line;
}).join("\n");
For ASS-to-SRT or ASS-to-VTT conversion, the source extracts the text field, removes {...} override tags, and turns \N or \n into line breaks. That makes the plain subtitle readable, but it cannot preserve ASS fonts, colors, positioning, karaoke effects, or other styling in SRT/VTT. Keeping the original file is part of the workflow, not an optional precaution.
Thinking in milliseconds also makes the direction of the correction less mysterious. If the subtitle appears after the speaker, the subtitle is late and the offset should be negative. If the words appear before the audio, the offset is positive. The interface explains this beside the number input and uses a 100 ms step, but it does not try to estimate the offset from the media itself. The human still supplies the observed difference; the code’s job is to apply it consistently to every parsed cue.
Limits and useful edge cases
The SRT parser separates blocks on blank lines and finds the line containing -->; malformed cues are filtered if their start time becomes NaN. The VTT parser begins at the first timing line and collects text until a blank line, so unusual cue metadata or nonstandard formatting may not round-trip perfectly. ASS parsing accepts the specific Dialogue: layer,start,end,... shape used by the regular expression; comments and other event types are left alone.
The browser reads uploaded files with FileReader, copies results with navigator.clipboard, and downloads a generated text blob. That keeps subtitle content local, but clipboard permissions can still fail in a restricted browser context. Finally, a single constant offset cannot repair subtitles whose drift grows over time; that needs a time-stretch or manually corrected timeline, not this batch shift.
I turned this implementation into a small free tool: Subtitle Timecode Fixer & Format Converter.