DEV Community

yosuga
yosuga

Posted on

Why auto-generated captions break English lines in the wrong place (and how to fix it with spaCy)

Netflix has written rules for where a subtitle line may break. Most caption tools ignore them. Here is a 100-line spaCy implementation, and the API I built around it.

I build apps and APIs that generate how-to and ad videos automatically, and captions are always the hardest part to get right. Transcription is automatic now, but the line breaks it produces look like this:

Today I'll show you how to add captions to
any video in just one click.
Enter fullscreen mode Exit fullscreen mode

Nothing is misspelled, yet the line ends on "to" and its object starts the next line. Your eye has to jump back to rejoin "to any video". On a phone, in a Short that is on screen for two seconds, that small stumble is the difference between reading the caption and giving up on it.

This post covers the rules professional subtitlers use, a runnable Python implementation with spaCy, the same idea for word-by-word Shorts captions, and what is still missing before you have production subtitles.

1. Why character counts are not enough

Almost every caption tool wraps the same way: fill the first line up to the limit (often 42 characters), then break at the last space that fits. Spaces tell you where you may break; they say nothing about where you should. Another example from that greedy approach:

Please make sure that the new settings are
saved before you close the window.
Enter fullscreen mode Exit fullscreen mode

"are" is stranded on the first line, away from "saved". And in word-by-word captions for Shorts, fixed groups of four words cut phrases apart:

Hey everyone, welcome back! | Today I will show | you how to add | captions to any video | in just one click.
Enter fullscreen mode Exit fullscreen mode

2. The rules professionals use

Netflix publishes a Timed Text Style Guide for subtitle vendors, and its line-treatment rules are a good summary of what the industry agrees on:

  • Break after punctuation, before conjunctions and before prepositions.
  • Do not separate an article or adjective from its noun, a first name from a last name, a subject pronoun from its verb, a prepositional verb from its preposition, or a verb from its auxiliary, reflexive pronoun or negation ("have been", "does not").
  • When there are several valid options, prefer a bottom-heavy pyramid: the second line a little longer than the first.

Every one of these rules is about grammar, not length. So the fix is to let a parser tell us which words belong together.

3. Implementing the rules with spaCy

spaCy's small English model gives every word a part of speech and a link to the word it depends on. That is enough to turn the rules above into a cost for each gap between two words: infinity where a rule forbids a break, a small number where a rule recommends one, and, everywhere else, a cost that grows with the number of grammatical links the break would cut.

pip install spacy
python -m spacy download en_core_web_sm
Enter fullscreen mode Exit fullscreen mode
"""Break English captions where a reader expects it (Netflix-style rules + spaCy).

    pip install spacy
    python -m spacy download en_core_web_sm
    python wrap_en.py
"""
import spacy

nlp = spacy.load("en_core_web_sm")
INF = float("inf")


def gap_cost(doc, i):
    """Cost of breaking the line between doc[i - 1] and doc[i]."""
    left, right = doc[i - 1], doc[i]
    if right.is_punct or right.text.startswith("'") or right.text == "n't":
        return INF                                   # never start a line with punctuation or a clitic
    if left.dep_ in ("det", "poss") or left.pos_ == "ADJ" and right.pos_ in ("NOUN", "PROPN"):
        return INF                                   # the + noun, our + noun, adjective + noun
    if left.pos_ in ("ADP", "CCONJ", "SCONJ", "PART") or left.tag_ in ("WDT", "WP", "WRB"):
        return INF                                   # a line never ends on "to", "and", "how", "of"...
    if left.pos_ == "PROPN" and right.pos_ == "PROPN":
        return INF                                   # first name + last name
    if left.pos_ == "AUX" or right.dep_ in ("neg", "prt"):
        return INF                                   # does not, have been, turn off
    if left.dep_ in ("nsubj", "nsubjpass") and left.pos_ == "PRON":
        return INF                                   # subject pronoun + verb: "I / work"
    if left.text in ".?!":
        return 0
    if left.text in ",;:":
        return 1
    if right.pos_ in ("CCONJ", "SCONJ") or right.tag_ in ("WDT", "WP", "WRB"):
        return 3                                     # before and/but/because/that/how
    if right.pos_ == "ADP":
        return 4                                     # before a preposition
    # otherwise: how many grammatical links would the break cut? fewer = more natural
    crossing = sum(1 for t in doc if (t.i < i) != (t.head.i < i))
    return 5 + 3 * crossing


def wrap(text, max_chars=42):
    """One line if it fits; otherwise the cheapest two-line split (slightly bottom-heavy)."""
    if len(text) <= max_chars:
        return [text]
    doc = nlp(text)
    best, best_score = None, INF
    for i in range(1, len(doc)):
        top, bottom = doc[:i].text, doc[i:].text
        if len(top) > max_chars or len(bottom) > max_chars:
            continue
        balance = abs(len(top) - len(bottom)) * 0.3 + (2 if len(top) > len(bottom) else 0)
        score = gap_cost(doc, i) + balance
        if score < best_score:
            best, best_score = (top, bottom), score
    if best is None:
        raise ValueError("does not fit on two lines; split the cue first")
    return list(best)


def chunks(text, max_words=4):
    """Short-form captions: up to max_words words per screen, cut where the grammar allows."""
    doc = nlp(text)
    words = [t for t in doc if not t.is_punct]
    n = len(doc)
    # dynamic programming over token positions: best[i] = cheapest way to caption doc[:i]
    best = [0.0] + [INF] * n
    back = [0] * (n + 1)
    for end in range(1, n + 1):
        if end < n and gap_cost(doc, end) == INF:
            continue
        for start in range(end - 1, -1, -1):
            group = [t for t in doc[start:end] if not t.is_punct]
            if len(group) > max_words:
                break
            if not group or best[start] == INF:
                continue
            if any(t.text in ".?!" for t in doc[start:end - 1]):
                continue                             # a screen never runs across a sentence end
            cost = best[start] + (gap_cost(doc, end) if end < n else 0) + (6 if len(group) == 1 else 0)
            if cost < best[end]:
                best[end], back[end] = cost, start
    out, end = [], n
    while end > 0:
        out.append(doc[back[end]:end].text)
        end = back[end]
    return out[::-1]


def naive(text, max_chars=42):
    """What most tools do: fill the first line, break at the last space that fits."""
    cut = text.rfind(" ", 0, max_chars + 1)
    return [text[:cut], text[cut + 1:]]


if __name__ == "__main__":
    for s in [
        "Today I'll show you how to add captions to any video in just one click.",
        "Please make sure that the new settings are saved before you close the window.",
    ]:
        print("naive:", " / ".join(naive(s)))
        print("wrap: ", " / ".join(wrap(s)))
        print()
    print(" | ".join(chunks("Hey everyone, welcome back! Today I will show you how to add captions to any video in just one click.")))
Enter fullscreen mode Exit fullscreen mode

Output:

naive: Today I'll show you how to add captions to / any video in just one click.
wrap:  Today I'll show you how to add captions / to any video in just one click.

naive: Please make sure that the new settings are / saved before you close the window.
wrap:  Please make sure that the new settings / are saved before you close the window.

Hey everyone, welcome back! | Today | I will show you | how to add captions | to any video | in just one click.
Enter fullscreen mode Exit fullscreen mode

A few things worth pointing out:

  • Forbidden breaks are constraints, not penalties. If "do not split the from its noun" were just a high cost, a lopsided line could still outweigh it now and then, and those rare failures are exactly the ones viewers notice.
  • Counting crossed links is a cheap stand-in for phrase structure. A break that cuts few dependency links falls between phrases; one that cuts many falls inside a phrase. You get phrase-aware breaks without writing a grammar.
  • The same costs drive short-form chunking. chunks() is a small dynamic program: it picks screens of up to four words so that the cuts land on cheap gaps, never crosses a sentence end, and avoids one-word screens unless the grammar leaves no better choice (as with "Today" above).

4. What is still missing for real subtitles

The code above decides where text breaks. A subtitle file needs more:

  • Timing. Each cue needs a start and an end. With word timestamps from your speech-to-text engine you can take them from the first and last word of the cue; without them you are stuck estimating from character counts, which drifts as soon as the speaker changes pace.
  • Width, not characters. "WWWW" and "iiii" are both four characters. If you render with a proportional font, measure text width in the font you burn with, or long lines of wide letters will overflow.
  • Burn-in details. Display fonts often lack glyphs for accented or non-Latin text, styles authored for 1920×1080 drift when burned into 1080×1920, and karaoke-style highlighting needs word timestamps that never overlap or run past the end of the clip.
  • Other languages. Spanish, French, German and Portuguese follow similar rules with their own function words. Japanese and Chinese have no spaces at all, so every character boundary is a candidate and the problem gets much harder.
  • Songs. Speech-to-text engines are built for speech. On singing, words get missed and word timestamps drift, sometimes by seconds, so line breaks and karaoke highlighting land in the wrong place no matter how good the wrapping is.

5. Show dev: the API I built around this

For those video tools I wrapped all of this, transcription with word timestamps, phrase-aware line breaks, width-based wrapping and burn-in, into one API and published it on RapidAPI as the Video Subtitle & Transcript API. It uses spaCy for the line breaks like the code above, measures lines in rendered width rather than characters, burns captions in for English, Japanese and seven more languages, and comes with 12 caption styles including karaoke fill and word pop.

The same clip in all 12 caption styles

One request from video to burned-in captions

When you start from a video, one asynchronous job does everything:

curl -X POST https://video-subtitle-transcript-api1.p.rapidapi.com/v1/jobs \
  -H "X-RapidAPI-Key: $RAPIDAPI_KEY" -H "Content-Type: application/json" \
  -d '{
    "source_url": "https://caption.yosuga.dev/samples/video/demo-streamer.mp4",
    "language": "en",
    "mode": "clean",
    "outputs": ["json", "srt", "vtt", "ass"],
    "caption_style": {"preset": "karaoke_fill"},
    "cut_candidates": true,
    "burn_in": true
  }'
Enter fullscreen mode Exit fullscreen mode

Poll GET /v1/jobs/{id} until status is completed. You get word-level timestamps, wrapped cues, a cut list of fillers and silences with times, signed URLs for the files, and, if you set burn_in, the video with captions rendered in. Billing is per minute of media, counted once when the completed result is first returned; failed jobs are free.

A 10-minute video takes about a minute. A 2-hour recording takes about 7.

If you automate with n8n, there is a ready-made template in the n8n template library: a form takes a video URL and a style and returns the captioned MP4. You can also try every style in the browser with Caption Studio. There is a free tier for evaluation. Like any speech-to-text service, it is built for speech, not songs.

Here is a two-minute walkthrough of the API, Caption Studio and all 12 styles:

(The previews use caption_style.font_size_scale: 1.3 so the captions read at this size; the default is 1.0. The streamer in the demo clip was generated with Veo; she is not a real person.)

If you only need line breaks, the code in section 3 is all you need. I would be glad to hear how others handle this, especially for languages I have not covered.

Top comments (0)