Realign Drifting Subtitles

Updated · By

Treat the anchors as a piecewise-linear map from observed time to correct time. For each cue time, binary search the sorted anchors for the segment around it and interpolate along that segment; before the first anchor or after the last, extend the nearest segment. A single anchor is a constant shift. O(log m) per time for m anchors.

mediumPattern: Binary searchFrom Devana's Netflix question set

Problem

After a re-encode, a film's subtitles drift out of sync with the audio. Sometimes the error is a constant offset; sometimes it grows through the film, which is what a frame rate change does. You have the subtitle cues and a few anchors where someone has marked the correct time.

Write realign_subtitles(cues, anchors). Each cue is a dict with start and end in seconds plus other fields that pass through unchanged. Each anchor is (observed, correct). Return the corrected cues.

def realign_subtitles(cues: list[dict], anchors: list[tuple[float, float]]) -> list[dict]:

This is one of Devana's own questions, written in the style of Netflix's interviews. It is not a question leaked from Netflix, and Devana is not affiliated with Netflix.

Constraints

  • Anchors have distinct observed times and may arrive unsorted.
  • Between two anchors the correction is linear. Before the first or after the last anchor, the nearest segment's line continues.
  • A single anchor gives a constant offset. With no anchors, cues come back unchanged.
  • Start and end are corrected independently, so a cue's duration stretches when the drift does.
  • Up to 10^5 cues and 10^3 anchors; results are rounded to 6 decimal places.

Examples

Example 1

Input:  cues = [{"start": 600, "end": 602, "text": "a"}]
        anchors = [(600, 599.5), (3600, 3599.5)]
Output: [{"start": 599.5, "end": 601.5, "text": "a"}]

The error is half a second at both anchors: a constant offset, so every cue moves 0.5 seconds earlier.

Example 2

Input:  cues = [{"start": 150, "end": 250}]
        anchors = [(0, 0), (100, 100), (200, 250)]
Output: [{"start": 175, "end": 325}]

Between 100 and 200 observed seconds, correct time runs 1.5 times as fast, so 150 maps to 175. 250 is past the last anchor, so the same segment continues: 100 + 150 × 1.5 = 325.

Hints

Open one at a time, and try the problem again after each.

Hint 1

Plot correct time against observed time using the anchors. What shape do you get?

Hint 2

For a single time t, which two anchors determine its correction?

Hint 3

With anchors sorted by observed time, the segment for t starts at the last anchor at or before t. That is a binary search.

Hint 4

Clamp the segment index, so times before the first anchor use the first segment and times after the last use the last.

Approach: from brute force to optimal

Brute force: scan for the segment

For each cue time, walk the anchors until you pass it, then interpolate between the two around it.

Complexity: O(n × m) for n cues and m anchors

Binary search for the segment

Sort the anchors once. For a time t, bisect_right over the observed times gives the first anchor after t; the segment starts one before it, clamped to a valid segment. Then interpolate: y0 + (t - x0) × (y1 - y0) / (x1 - x0).

If the cues are known to be in time order, a pointer that only moves forward through the anchors replaces the binary search, for O(n + m) overall. The binary search is the safe default when the order is not guaranteed.

Complexity: O(m log m) to sort and O(log m) per cue time, so O((n + m) log m); O(m) extra space

How to say it in the interview

Say "piecewise-linear interpolation" early; it tells the interviewer you have recognized the shape. Then explain how you find the segment, why the end segments extend, and that one anchor is a pure shift. Point out that start and end are corrected separately, which is why durations can change.

Solution

Python

from bisect import bisect_right

def realign_subtitles(cues, anchors):
    points = sorted(anchors)
    if not points:
        return [dict(cue) for cue in cues]
    observed = [x for x, _ in points]

    def correct(t):
        if len(points) == 1:
            return t + points[0][1] - points[0][0]
        # The segment starts at the last anchor at or before t, clamped so
        # times outside the anchors extend the first or last segment.
        i = min(max(bisect_right(observed, t) - 1, 0), len(points) - 2)
        (x0, y0), (x1, y1) = points[i], points[i + 1]
        return y0 + (t - x0) * (y1 - y0) / (x1 - x0)

    fixed = []
    for cue in cues:
        moved = dict(cue)                  # every other field passes through
        moved["start"] = round(correct(cue["start"]), 6)
        moved["end"] = round(correct(cue["end"]), 6)
        fixed.append(moved)
    return fixed

JavaScript

function realignSubtitles(cues, anchors) {
  const points = [...anchors].sort((p, q) => p[0] - q[0]);
  if (points.length === 0) return cues.map((cue) => ({ ...cue }));

  const correct = (t) => {
    if (points.length === 1) return t + points[0][1] - points[0][0];
    // The first anchor after t; the segment starts one before it, clamped.
    let lo = 0, hi = points.length;
    while (lo < hi) {
      const mid = (lo + hi) >> 1;
      if (points[mid][0] <= t) lo = mid + 1;
      else hi = mid;
    }
    const i = Math.min(Math.max(lo - 1, 0), points.length - 2);
    const [x0, y0] = points[i];
    const [x1, y1] = points[i + 1];
    return y0 + (t - x0) * (y1 - y0) / (x1 - x0);
  };

  const round6 = (x) => Math.round(x * 1e6) / 1e6;
  return cues.map((cue) => ({ ...cue, start: round6(correct(cue.start)), end: round6(correct(cue.end)) }));
}

Both versions are tested against the same cases, including every example on this page.

  • The binary search finds the first anchor after t; one before it is where the segment starts. Clamping handles both ends at once.
  • Each cue is copied before it is changed, which leaves the caller's list intact and passes every other field through.
  • At an anchor, both neighboring segments give the same answer, so it does not matter which one the search picks.

Follow-up questions

How do you detect drift automatically, without manual anchors?

Align speech in the audio with cue times: detect where speech starts, match those moments to cue starts, and fit a line through the matches with a robust method such as RANSAC so a few bad matches do not tilt it. The matched pairs become anchors.

What causes a linear drift in practice?

A frame rate mismatch. Subtitles timed against a 25 frames per second version and played against a 23.976 version run about 4% off, so the error grows steadily through the film.

How would you validate the corrected track before publishing?

Check that every cue still ends after it starts, that cues do not overlap more than they did before, and that spot checks against the audio fall within a tolerance. Reject a correction whose slope is implausible, say more than a few percent from 1.

What if two anchors share an observed time?

The slope would divide by zero. Reject the input, or merge the two by averaging their correct times, and say which you chose.

Common mistakes

  • Applying the average offset to everything, which leaves the start and end of the film wrong in opposite directions when the drift grows.
  • Leaving cues before the first anchor uncorrected, which puts a visible jump at that anchor.
  • Moving the end by the start's correction, so durations never stretch.
  • Binary searching anchors that were never sorted.

Practice it in a mock interview

Reading a solution and explaining one under questioning are different skills. The button below starts this problem as a voice mock interview with Josh, Devana's AI interviewer, with an editor that runs your code.

Practice this problem in a mock interview

Practice this problem out loud

Josh, Devana's AI interviewer, runs this exact problem as a voice mock interview, asks follow-ups like the ones above, and scores you out of 100. The Free plan has 3 interviews a month, no card needed.

Practice this problem in a mock interview