Subtitle Sync: Fix, Repair and Merge Caption Tracks
MediumAI-assisted coding · AI-Assisted Debugging
Subtitle Sync: Fix, Repair and Merge Caption Tracks
You have joined the captions team at a video platform. Partners upload .srt subtitle files; the subs toolkit parses them, retimes them when a partner's cut differs from ours, and (soon) checks their quality and merges language tracks. Support has a pile of tickets, QA wants quality rules before a title can launch, and product has a new requirement for bilingual captions. You have about an hour and an AI assistant. The assistant is allowed to write code; you are responsible for what ships.
Run everything through run(operations) in subs/api.py. The starter code is the source of truth for conventions the README only points at: subs/model.py (cue invariants), subs/textutil.py (what counts as a character), subs/srt.py and subs/timecode.py (file format), subs/config.py (the gap constant). tests/test_srt.py holds a few failing examples for Part 1. Hidden tests re-check earlier Parts after later ones.
How run(operations) works
operations is a list; each operation is [name, *args]. run starts with an empty workspace of named tracks and returns exactly one result per operation. Failures are returned as "error: <code>" strings. Check order: unknown operation, then argument count and types (error: invalid arguments), then track lookup (error: no such track), then the operation's own checks. A failed operation changes nothing.
| Operation | Arguments | Returns | Errors (besides the common ones) |
|---|---|---|---|
load | name, text | number of cues | malformed track |
dump | name | SRT text | |
delete | name, indexes | cues remaining | no such cue |
shift | name, offset_ms | number of cues dropped | |
rescale | name, first, first_target, second, second_target | number of cues dropped | no such cue, bad anchors |
info | name | {"cues", "start", "end", "chars"} | |
synth | name, count, period_ms, duration_ms, seed | count | |
fix_overlaps (Part 2) | name, min_duration_ms | {"trimmed", "delayed"} | |
reading_report (Part 2) | name, max_cps | list of rows | |
merge (Part 3) | out_name, primary_name, secondary_name | stats dict |
Types: names are non-empty strings; every number is an integer (booleans are not integers); indexes is a non-empty list of integers; synth's count, period_ms, duration_ms are >= 1; min_duration_ms is >= 1; max_cps is >= 0. Loading or merging into an existing name replaces that track.
Track model
A track is an ordered list of cues: index (1-based position), start, end (integer ms, 0 <= start < end) and text lines. A track is always ordered by start (equal starts keep their order) and numbered 1..N with no gaps. Numbers written in a file are ignored: load sorts stably by start and renumbers. info returns the number of cues, the first cue's start (null if empty), the largest end (null if empty) and the total of visible_chars over every cue's lines. synth builds a reproducible track (see subs/fixtures.py); the tests use it to get big tracks from one operation.
Part 1: Fix the reader, the writer and the resync tool (tickets CAP-412, CAP-419, CAP-431)
Goal: make load, dump, delete, shift and rescale follow the rules below. The code already exists; it has bugs. Reports from partners and QA:
- "
00:00:01,5is showing up at 5 ms." Some exporters drop trailing zeros. - "Our file with a blank line inside a subtitle is rejected as malformed."
- "After deleting cues or resyncing, numbers in the file have gaps, and anchoring by cue number is off."
- "Shifting a file earlier left zero-length cues stuck at 00:00:00,000."
- "A linear stretch is off by a millisecond on some cues, and a few land on the wrong side of a tie."
Rules
- Timecodes.
HH:MM:SS,mmm: hours have at least two digits, minutes and seconds are00-59; the separator before the fraction is,or.; the fraction has 1-3 digits and is a decimal fraction of a second, so,5and,50are 500 ms and,05is 50 ms.dumpalways writesHH:MM:SS,mmm. - File layout. Each cue is an index line (digits), a timing line
start --> end, then zero or more text lines. A cue's text ends at a blank line (empty or whitespace-only) from which the next non-blank line is an index line followed by a timing line, or at the end of the file. Blank lines inside the text are kept (as empty lines); blank lines at the end of the text are not.\r\nis accepted. loaderrors. Anything that is not that layout, a bad timecode, orend <= startgiveserror: malformed track, and an existing track of that name is left untouched. An empty file is a valid track with 0 cues.dump. Blocksindex,start --> end, text lines, separated by one blank line, with a single\nat the end; an empty track dumps as"". Dumping then loading gives the same track.delete. All indexes must exist (duplicates are fine) or the result iserror: no such cueand nothing is deleted. Remaining cues are renumbered; the result is how many remain.shift. Addoffset_msto every start and end. A time below 0 becomes 0. A cue whoseend <= startafter that clamp is dropped. Dropped cues leave no gap: the rest are renumbered. Returns the number dropped.rescale. The anchors say: "cuefirstshould start atfirst_targetand cuesecondatsecond_target". Every start and endtis mapped tofirst_target + (t - start_first) * (second_target - first_target) / (start_second - start_first), rounded to an integer half up (exact halves go toward positive infinity, so-1.5becomes-1and2.5becomes3), computed without floating point. Then clamp, drop and renumber exactly as inshift. Cue numbers are the current numbers. Checks in order: both indexes exist (no such cue,firstbeforesecond), thenbad anchorsiffirst >= second, if the two cues start at the same time, or ifsecond_target <= first_target. Targets may be negative.
Example
[["load", "en", "1\n00:00:01,5 --> 00:00:03,250\nHello\n\nthere\n\n2\n00:00:04,000 --> 00:00:05,000\nBye\n"],
["shift", "en", -3500],
["dump", "en"]]returns [2, 1, "1\n00:00:00,500 --> 00:00:01,500\nBye\n"]: cue 1 is entirely below zero and disappears.
With cues A 1000-1800, B 2000-2900, C 4000-4500, ["rescale", "en", 1, 1500, 3, 2500] returns 0; the cues become 1500-1767, 1833-2133, 2500-2667.
Part 2: Quality rules (ticket CAP-455)
Goal: QA will not launch a title unless its captions pass two checks. Add fix_overlaps and reading_report. Both operate on the current state of the track.
fix_overlaps(name, min_duration_ms) makes every cue start at or after the end of the cue before it. Go through the cues left to right; for each cue after the first, compare it with the cue before it as it is now (already repaired). The cues overlap when cue.start < previous.end (touching is fine). Then:
- Let
new_end = cue.start - 1(the 1 ms isGAP_MSinsubs/config.py). - If
new_end - previous.start >= min_duration_ms, trim:previous.end = new_end. - Otherwise leave
previousalone and delay this cue:shift = previous.end + GAP_MS - cue.start; addshiftto both its start and end (its duration is kept).
Returns {"trimmed": <count>, "delayed": <count>}. Delaying can make a cue overlap the next one; that is handled when the next cue is reached. Order and numbering never change.
reading_report(name, max_cps) lists cues that are too fast to read. A cue's chars is visible_chars(lines) from subs/textutil.py (styling tags removed, each line trimmed, line breaks not counted, inner spaces counted). A cue is flagged when it is strictly above the limit: chars * 1000 > max_cps * duration_ms. Each row is [index, chars, duration_ms, cps_tenths], where cps_tenths is chars / duration_ms in characters per second, times 10, rounded half up. Rows are sorted fastest first by the exact ratio chars / duration_ms (not the rounded value), ties by index.
Example
[["load", "en", "<A 0-2000, B 1500-3000, C 3500-4000, D 3600-3900>"],
["fix_overlaps", "en", 700],
["dump", "en"]]returns {"trimmed": 1, "delayed": 1} and the cues A 0-1499, B 1500-3000, C 3500-4000, D 4001-4301: A is trimmed (1499 ms is enough); trimming C would leave 99 ms, so D is delayed by 401 ms.
For cues 0-2000 "Run!", 2000-4000 "<i>I can't</i>" + " believe you did that ", 4000-5000 "Get out of here right now, please", ["reading_report", "en", 13] returns [[3, 33, 1000, 330], [2, 27, 2000, 135]].
Part 3: Bilingual tracks (ticket CAP-470) and a rule change
Goal: product wants one track carrying two languages. Add merge(out_name, primary, secondary) and change the reading-speed rule for the resulting cues. The two source tracks are never modified.
Pairing. Cues p (primary) and s (secondary) may pair when their intervals overlap by at least half of the shorter cue: overlap = min(ends) - max(starts), and overlap > 0 and 2 * overlap >= min(duration_p, duration_s). Go through the primary cues in track order. Each takes the unused secondary cue that may pair with it and has the largest overlap; equal overlaps go to the earlier secondary cue. A secondary cue pairs at most once. Everything else stays alone.
Merged cues. A paired cue spans the union (min start, max end). Its lines are the primary lines followed by the secondary lines, each non-empty secondary line wrapped as <i>line</i> (empty lines stay empty; existing tags are not touched). A primary-only cue keeps its lines; a secondary-only cue has just its wrapped lines. The result is ordered by start time; at equal starts, cues containing a primary language (paired or primary-only) come first in primary order, then secondary-only cues in secondary order. It is numbered 1..N and stored under out_name. Result: {"cues": N, "paired": ..., "primary_only": ..., "secondary_only": ...}. Check order: primary_name is looked up before secondary_name. Source tracks that are themselves merged are treated as plain tracks.
Rule change for reading_report. A viewer reads one language at a time. For a merged cue, chars is now the larger of visible_chars(primary lines) and visible_chars(secondary lines) (a secondary-only cue counts only its secondary lines). The rest of Part 2 is unchanged, and info's chars still counts every line. A merged track keeps this behaviour through shift, rescale, delete and fix_overlaps. A merged track that is dumped and loaded again is a plain track.
Scale. Hidden tests merge tracks of thousands of cues each; comparing every primary cue with every secondary cue will not finish in time.
Example
[["load", "en", "<0-2000 Hello; 3000-5000 Where to?>"],
["load", "fr", "<200-2100 Bonjour; 2500-2900 Où ça ?; 4000-6000 Où ?>"],
["merge", "bi", "en", "fr"],
["dump", "bi"]]returns [2, 3, {"cues": 3, "paired": 2, "primary_only": 0, "secondary_only": 1}, "1\n00:00:00,000 --> 00:00:02,100\nHello\n<i>Bonjour</i>\n\n2\n00:00:02,500 --> 00:00:02,900\n<i>Où ça ?</i>\n\n3\n00:00:03,000 --> 00:00:06,000\nWhere to?\n<i>Où ?</i>\n"]. ["reading_report", "bi", 2] then returns [[2, 7, 400, 175], [1, 7, 2100, 33], [3, 9, 3000, 30]].
Notes
config.py,errors.py,workspace.pyandfixtures.pyare known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later Parts must not break earlier ones; hidden tests re-check earlier behavior on merged tracks too.
- Parts 1 and 2 are the core; Part 3 is the stretch.
Example 1
- Input:
- [["load","en","1\n00:00:01,5 --> 00:00:03,250\nHello\n\nthere\n\n2\n00:00:04,000 --> 00:00:05,000\nBye\n"],["shift","en",-3500],["dump","en"]]
- Output:
- [2,1,"1\n00:00:00,500 --> 00:00:01,500\nBye\n"]
- Explanation
- The first cue has a blank line inside its text and a fraction of "5" (= 500 ms). Shifting by -3.5 s pushes cue 1 entirely below zero, so it is dropped and the remaining cue becomes cue 1.
Example 2
- Input:
- [["load","en","..."],["fix_overlaps","en",700],["reading_report","en",13]]
- Output:
- [4,{"trimmed":1,"delayed":1},[[3,33,1000,330],[2,27,2000,135]]]
- Explanation
- Overlapping cues are trimmed or delayed left to right; the report lists cues above 13 characters per second, fastest first, with the speed in tenths of a character per second.
Guidelines
- Python 3 standard library only
- All times are integer milliseconds; no floating-point arithmetic decides any output
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Part 3 inputs contain thousands of cues per track, generated by the synth operation