This subtitle file parses without a single error. Try reading the third caption before it disappears.
1 00:00:00,500 --> 00:00:03,500 The first caption stays on screen. 2 00:00:02,800 --> 00:00:04,900 This one begins too early. 3 00:00:05,000 --> 00:00:05,600 This sentence is difficult to read in six tenths of a second. The first two cues overlap for 700 milliseconds. The third gives you 0.6 seconds to read 61 characters.
Every timestamp is recognizable. Every cue has a positive duration. That is a low bar for something a person has to watch.
I'm building SubtitlePop, a tool for generating, editing and styling video captions. For this public development note, I want to share a small, reproducible part of the work: inspecting subtitle timing before deciding what to change.
The code below is from subtitle-timing-kit, an open-source companion I published separately. It is an independent implementation, not the commercial website's source code. The examples are synthetic; they aren't customer transcripts or a transcription-accuracy benchmark.
The result that a successful parse hides
I ran the repository's four-cue samples/overlap.srt through the parser and inspector. The fourth cue is an ordinary three-second caption after the three shown above.
| Check | Result |
|---|---|
| Parser issues | 0 |
| Inspector errors | 0 |
| Inspector warnings | 3 |
| Overlapping intervals | Cue 2 overlaps cue 1 by 700 ms |
| Fast-reading flag | Cue 3: 101.7 characters per second |
| Long-line flag | Cue 3 exceeds the configured 42-character limit |
You can reproduce it with Node.js 22 or later:
git clone https://github.com/j-river/subtitle-timing-kit.git cd subtitle-timing-kit node src/cli.js inspect samples/overlap.srt Or call the API from the repository:
import { readFile } from 'node:fs/promises'; import { parseSubtitles, inspectCues } from './src/index.js'; const source = await readFile('samples/overlap.srt', 'utf8'); const parsed = parseSubtitles(source); const report = inspectCues(parsed.cues); // Check both. A parser may return some cues alongside parse errors. console.log([...parsed.issues, ...report.issues]); console.log(report.metrics[2]); // durationMs: 600, characters: 61, cps: 101.666... The reading-speed calculation is simple: visible character count divided by duration in seconds. This implementation counts Unicode code points, includes spaces, and excludes line breaks after stripping supported markup.
That simplicity has limits. Code points aren't grapheme clusters. Different languages and audiences need different reading-speed assumptions. The default 20 characters/second and 42 characters/line are adjustable review thresholds, not accessibility certification.
The number earns its place by directing attention to a cue. It doesn't get to decide whether the caption is good.
Why I keep overlap warnings separate from errors
For an ordinary single-speaker caption track, two overlapping cues are worth checking. But simultaneous dialogue exists. So do deliberately positioned captions.
WebVTT permits overlapping subtitle cues. Treating every overlap as an invalid file would reject legitimate material. Automatically deleting one cue would be worse.
The companion inspector reports an overlap as a warning. A reversed or zero-length interval is an error. Those two kinds of feedback ask for different actions.
There is also an easy implementation mistake here. Consider these intervals, already sorted by start time:
A: 0 s -------------------- 10 s B: 1 s -- 2 s C: 3 s -- 4 s Comparing C only with B misses the fact that A is still active.
For a yes/no warning per later cue, track the furthest end time seen so far:
const ordered = [...cues].sort((a, b) => a.startMs - b.startMs); let furthest = null; for (const cue of ordered) { if (furthest && cue.startMs < furthest.endMs) { console.log('Review overlap', { cue, earlierCue: furthest }); } if (!furthest || cue.endMs > furthest.endMs) furthest = cue; } This sketch assumes the cue shapes and durations were checked first. It detects both later overlaps in that example. It doesn't enumerate every intersecting pair; that is a different requirement. Sorting dominates the cost at O(n log n).
Moving every cue doesn't repair the space between them
The next experiment was a global +1,500 ms shift.
| Cue | Before | After |
|---|---|---|
| 1 | 0.500–3.500 s | 2.000–5.000 s |
| 2 | 2.800–4.900 s | 4.300–6.400 s |
Still 700 ms of overlap. The rushed cue still lasts 0.6 seconds.
import { shiftCues, inspectCues } from './src/index.js'; const shifted = shiftCues(parsed.cues, 1500); const after = inspectCues(shifted.cues); console.log(after.issues.filter(i => i.code === 'OVERLAP').length); // 1 A constant offset can help when the whole track is consistently early or late. It preserves durations and the distances between cue boundaries.
If subtitles are correct at the start and progressively wrong near the end, a constant offset won't fix the whole track. Look for drift or a different edit of the video. You need reference points in the media to investigate that; an SRT file alone cannot prove speech alignment.
Moving captions earlier also introduces a choice near time zero. Moving [1.000, 3.000] by −1.500 seconds would produce a negative start.
The library rejects that by default. Explicit clipping produces [0.000, 1.500] and reports the changed index. That cue has lost half a second of display time. A cue shifted completely before zero is dropped and reported too.
Calling either operation “lossless repair” would hide something the editor needs to know.
The export can keep the words and lose the design
Timing is only one part of a subtitle workflow. Here's a small WebVTT input:
WEBVTT intro 00:01.000 --> 00:03.500 align:start position:10% <v Narrator><b>Hello</b> & welcome.</v> The companion toolkit's default plain-text SRT export is:
1 00:00:01,000 --> 00:00:03,500 Hello & welcome. The words and times survive. The positioning, speaker tag and bold markup do not. This exporter also omits WebVTT STYLE, REGION and NOTE blocks with warnings. Keeping the original matters.
That distinction also explains a product choice in SubtitlePop: a subtitle file and a styled video are different deliverables. Use a subtitle file when you want editable text in another workflow. Use a captioned MP4 when the appearance needs to be in the picture itself. The product also offers styled ASS export; the small open-source toolkit doesn't.
The product editor above uses a sample clip. The timing report in this article uses the separate synthetic SRT fixture.
What is public, and what still needs a person
The source and sample files are public under MIT. There is also a browser-based Timing Lab for trying the examples without installing anything. It processes subtitle text locally; it doesn't run speech recognition or play the source video.
SubtitlePop is the separate workflow for speech-to-captions, word-level editing, styling and video export. Its subtitle checker is another entry point if you already have a file.
The privacy boundary is specific: the local file tools work in the browser; automatic transcription sends the extracted audio for processing, and burning captions into an MP4 uploads the video for rendering. The commercial product has free limits and paid options. “Browser-based” alone doesn't mean “nothing gets uploaded.”
For this development note, I verified the fixture diagnostics, the shifted overlap, the nested-interval case, explicit clipping and the conversion above. That says nothing about transcription accuracy on accents, noisy recordings or multiple speakers. Those need a separate evaluation with audio and reference transcripts.
The unresolved product question I want feedback on is smaller: when a caption tool detects overlapping dialogue, should it leave export available with a warning, or require you to acknowledge each overlap first?
My current choice in the companion lab is to allow export when there are warnings but no errors. If you've shipped a subtitle editor, I'd like to know where that policy has failed you.
AI assistance was used to draft and revise this article and run the reproducible checks. The reported outputs were checked against the companion code; the examples are synthetic.



