DEV Community

Cryptea Sensei
Cryptea Sensei

Posted on

How to Translate SRT Subtitles with ChatGPT (Japanese to English) Without Breaking the Timing + a Free Python Checker

If you translate SRT subtitles with ChatGPT by pasting the whole file, cue numbers and timecodes can drift. This guide shows how to keep the structure locked, translate Japanese to English with any chatbot, and check the result with a free Python script that tests line length, reading speed, duration and gaps against Netflix's public timed text guidelines.

Disclosure: this article was drafted and edited with AI tools (#abotwrotethis). The Python script was run on Python 3.13.5 on October 20, 2026, and the checker output below is real. The English sample translation was written for this article by the AI tool that drafted it. The prompt in this article was not run in ChatGPT, Claude or Gemini, so no chatbot output is shown.

Pasting an SRT file into a chatbot and asking for English usually works for a few lines. On longer files, things break: two cues get merged, a cue number disappears, or a timecode comes back slightly different. Japanese adds its own problem. A sentence often runs across two or three cues, with the verb at the very end, so translating cue by cue gives you English that reads backwards.

The fix is to never let the model touch the parts it shouldn't change.


What must never change in an SRT

An SRT cue has three parts: an index, a timing line, and one or more lines of text, followed by a blank line.

4
00:00:10,000 --> 00:00:12,400
駅前の新しいカフェ、
Enter fullscreen mode Exit fullscreen mode

SRT is a simple plain-text format, so treat this structure as the contract. When you translate:

  • the number of cues must stay the same,
  • every index must stay the same,
  • every timing line must stay byte-for-byte the same,
  • only the text changes.

If a line is too long to read in time, shorten the English. Don't retime the cue during translation. Retiming is a separate job with its own rules.


Why Japanese to English is harder

Take cues 4–6 from the sample used below:

駅前の新しいカフェ、                  (the new café by the station,)
昨日オープンしたって聞いたんだけど      (I heard it opened yesterday, but)
行ってみない?                          (want to go?)
Enter fullscreen mode Exit fullscreen mode

Translated one cue at a time, cue 4 becomes "The new café in front of the station," with nothing to attach to. The English needs to know what comes next. The model needs the neighboring cues as context, while you still get back exactly one translation per cue.

Japanese also drops subjects ("すぐ追いつくから!" has no "I") and puts tone in sentence endings ("なんとかなるでしょ"). Both need context, not a dictionary.


Step 1: lock the structure (SRT to JSON)

The script below turns the SRT into a JSON list of id + text, plus the previous and next cue as read-only context. Timecodes stay in the original file and never go to the model.

python3 srtcheck.py to-json sample_ja.srt > cues.json
Enter fullscreen mode Exit fullscreen mode

Real output for cue 4:

{
  "id": "4",
  "text": "駅前の新しいカフェ、",
  "prev": "……まあ、いっか。なんとかなるでしょ。",
  "next": "昨日オープンしたって聞いたんだけど"
}
Enter fullscreen mode Exit fullscreen mode

Step 2: the prompt

Paste this into ChatGPT, Claude or Gemini, followed by the contents of cues.json. It's a template: it wasn't run in any chatbot for this article, so check the first reply with the script before you trust it. For long files, send 30 to 50 cues per message so the reply doesn't get cut off. Keep the batches in order.

You are translating Japanese subtitles into natural U.S. English.

Input: a JSON array. Each item has "id", "text" (translate this),
and "prev"/"next" (neighboring cues, for context only - do not translate them).

Rules:
1. Return a JSON array with exactly one item per input item, same order,
   each item {"id": <same id>, "text": <English>}. Nothing else.
2. Never merge, split, add or drop items. Never change an id.
3. When a Japanese sentence runs across several cues, translate the whole
   sentence, then divide the English across those same cues in a natural
   reading order. Each cue must still make sense where it breaks.
4. Keep each cue short: at most 2 lines, at most 42 characters per line.
   Use "\n" for a line break. Prefer shorter wording to long, literal wording.
5. Keep the speaker's tone (casual stays casual). Add a subject only when
   English needs one.
6. Use the single ellipsis character (…) instead of three dots,
   and no em or en dashes.
7. If something cannot be translated with confidence (a name, a pun),
   keep your best version and add "[CHECK]" at the end of that text.
Enter fullscreen mode Exit fullscreen mode

Rules 4 and 6 come from Netflix's English (USA) Timed Text Style Guide, which sets 42 characters per line and a maximum of two lines, says en and em dashes are not permitted, and asks for the single ellipsis character instead of three dots. You can remove them if your platform uses different rules.


Step 3: rebuild the SRT

Save the model's reply as translated_en.json. The script takes the timing lines from the original file and only the text from the JSON, so the model can't change a timecode even if it tries. If an ID is missing, it stops with an error.

This is the translation used for the test. It was written for this article by the AI tool that drafted it, not generated with the prompt above:

[
  {"id": "1", "text": "Wait, it's already this late?"},
  {"id": "2", "text": "Sorry, go on ahead.\nI'll catch up!"},
  {"id": "3", "text": "Oh well. It'll work out."},
  {"id": "4", "text": "That new café by the station"},
  {"id": "5", "text": "opened yesterday, I heard."},
  {"id": "6", "text": "Want to check it out?"},
  {"id": "7", "text": "Yeah!"}
]
Enter fullscreen mode Exit fullscreen mode
python3 srtcheck.py from-json sample_ja.srt translated_en.json > sample_en.srt
Enter fullscreen mode Exit fullscreen mode

Step 4: run the checker

python3 srtcheck.py check sample_ja.srt sample_en.srt
Enter fullscreen mode Exit fullscreen mode

The checker compares the two files and tests each English cue against these limits:

Check Limit Source
Cue count, indexes, timing lines identical to source (structure)
Characters per line 42 Netflix English (USA) guide
Lines per cue 2 Netflix English (USA) guide
Reading speed up to 20 characters per second (adult programs) Netflix English (USA) guide
Maximum duration 7 seconds Netflix General Requirements
Minimum duration 5/6 of a second (20 frames at 24 fps) Netflix General Requirements
Gap between cues at least 2 frames; in 24 fps content, gaps of 3 to 11 frames must be closed to 2 Netflix Subtitle Timing Guidelines
Punctuation no double spaces, no en/em dashes, ellipsis character Netflix English (USA) guide

Reading speed here is counted as all characters including spaces, excluding line breaks, divided by the cue's duration. Netflix's page gives the limit but not the counting method, so treat this as an estimate.

Frame-based checks assume 24 fps unless you pass --fps (for example --fps 25). Two Netflix pages word the minimum duration differently: General Requirements says "5/6 (five-sixths) of a second per subtitle event (e.g. 20 frames for 24fps)", while the Subtitle Timing Guidelines say "20 frames (or 4/5 sec)". At 24 fps, 20 frames is 5/6 of a second (0.833 s), not 4/5 (0.8 s). The script uses 5/6 of a second, the stricter value. The 3-to-11-frame gap rule is written for 24 fps content, so the script only applies it with --fps 24.

Real output for the translation above:

WARN    cue 1->2: gap 4.8 frames (24 fps rule: gaps of 3-11 frames must be closed to 2; source timing, fix in a timing pass)
WARN    cue 2->3: gap 7.2 frames (24 fps rule: gaps of 3-11 frames must be closed to 2; source timing, fix in a timing pass)
WARN    cue 4->5: gap 1.92 frames (min 2)
WARN    cue 7: duration 0.800s (min 5/6 s = 0.833s, 20 frames at 24 fps)

7 cues checked: 0 error(s), 4 warning(s)
Enter fullscreen mode Exit fullscreen mode

No errors: the cue count, indexes and timing lines all match, and every line fits. The four warnings are about the source timing, not the translation. The sample was timed by hand for this article. Gaps of 4.8 and 7.2 frames, an 80 ms gap (1.92 frames), and a 0.8-second cue would all need a timing pass under Netflix's rules. (The 0.8-second cue is exactly the case where the two Netflix pages disagree: it meets "4/5 sec" but not "5/6 of a second".) The checker flags them, so you know they were there before the translation.

Real output for a naive version, translated line by line and pasted back by hand:

ERROR   cue count changed: source 7, target 6
ERROR   cue 6: timecode changed (00:00:15,084 --> 00:00:17,500 -> 00:00:15,084 --> 00:00:19,000)
ERROR   cue 2: line has 73 chars (max 42): "I'm sorry, please go ahead first and I will catch up with you right away!"
ERROR   cue 3: line has 45 chars (max 42): "...Well, it's fine. It will work out somehow."
WARN    cue 1->2: gap 4.8 frames (24 fps rule: gaps of 3-11 frames must be closed to 2; source timing, fix in a timing pass)
WARN    cue 2: reading speed 28.1 CPS (max 20) - shorten the text, don't retime
WARN    cue 2->3: gap 7.2 frames (24 fps rule: gaps of 3-11 frames must be closed to 2; source timing, fix in a timing pass)
WARN    cue 3: three dots; use the single ellipsis character
WARN    cue 4->5: gap 1.92 frames (min 2)

6 cues checked: 4 error(s), 5 warning(s)
Enter fullscreen mode Exit fullscreen mode

These are the kinds of mistakes this article is about: cues 6 and 7 were merged into one (and its end time stretched), a literal line ran to 73 characters at 28.1 characters per second, and three dots were used where Netflix asks for the ellipsis character.

The exit code is 1 when there are errors and 0 when there are only warnings, so you can use it in a script or a CI job.


Fixing what the checker flags

  • Line too long / reading speed too high: shorten the English. "Sorry, go on ahead. I'll catch up!" says what "I'm sorry, please go ahead first and I will catch up with you right away!" says, in less than half the characters. You can also ask the model to shorten only the flagged IDs.
  • Cue count or timecode changed: don't fix it by hand. Rebuild from the JSON with from-json, which always uses the source timing.
  • Gaps and durations: these are timing issues in the source. Fix them in your subtitle editor as a separate timing pass, following Netflix's Subtitle Timing Guidelines.

What still needs a person who reads Japanese

The checker can tell you a line fits. It can't tell you it's right. Have someone who reads Japanese check:

  • names and how they're romanized,
  • jokes, puns and wordplay,
  • politeness levels (keigo) and how formal the English should sound,
  • anything you marked [CHECK].

The full script

Standard library only. Save it as srtcheck.py.

#!/usr/bin/env python3
"""srtcheck.py - round-trip an SRT through JSON and check a translated SRT.

  python3 srtcheck.py to-json  source.srt  > cues.json
  python3 srtcheck.py from-json source.srt translated.json > target.srt
  python3 srtcheck.py check    source.srt  target.srt [--fps 24]

Standard library only. Limits come from Netflix's public Timed Text Style Guide
pages (English (USA), General Requirements, Subtitle Timing Guidelines).
--fps sets the frame rate used for frame-based checks (default 24).
"""
import json, re, sys

MAX_CHARS_PER_LINE = 42   # English (USA) guide, II.3
MAX_LINES = 2             # English (USA) guide, II.13
MAX_CPS = 20.0            # English (USA) guide, II.17 (adult programs)
MAX_DURATION = 7.0        # General Requirements, 1. Duration
MIN_DURATION = 5 / 6      # General Requirements, 1. Duration: 5/6 s (20 frames at 24 fps)
MIN_GAP_FRAMES = 2        # Timing Guidelines, 5: minimum 2 frames between subtitles

TC = re.compile(r"(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})")

def parse(path):
    text = open(path, encoding="utf-8-sig").read().replace("\r\n", "\n")
    cues = []
    for block in re.split(r"\n\s*\n", text.strip()):
        lines = block.split("\n")
        if len(lines) < 2 or not TC.search(lines[1]):
            raise SystemExit(f"Cannot parse block:\n{block}")
        m = TC.search(lines[1]).groups()
        t = [int(x) for x in m]
        start = t[0]*3600 + t[1]*60 + t[2] + t[3]/1000
        end = t[4]*3600 + t[5]*60 + t[6] + t[7]/1000
        cues.append({"id": lines[0].strip(), "timing": lines[1].strip(),
                     "start": start, "end": end, "lines": lines[2:]})
    return cues

def to_json(src):
    cues = parse(src)
    out = []
    for i, c in enumerate(cues):
        out.append({"id": c["id"], "text": "\n".join(c["lines"]),
                    "prev": "\n".join(cues[i-1]["lines"]) if i else "",
                    "next": "\n".join(cues[i+1]["lines"]) if i + 1 < len(cues) else ""})
    print(json.dumps(out, ensure_ascii=False, indent=2))

def from_json(src, translated):
    cues = parse(src)
    tr = {str(x["id"]): x["text"] for x in json.load(open(translated, encoding="utf-8"))}
    missing = [c["id"] for c in cues if c["id"] not in tr]
    if missing:
        raise SystemExit(f"Translated JSON is missing ids: {', '.join(missing)}")
    blocks = [f'{c["id"]}\n{c["timing"]}\n{tr[c["id"]].strip()}' for c in cues]
    print("\n\n".join(blocks) + "\n")

def check(src, tgt, fps=24.0):
    a, b = parse(src), parse(tgt)
    errors, warnings = [], []
    if len(a) != len(b):
        errors.append(f"cue count changed: source {len(a)}, target {len(b)}")
    for x, y in zip(a, b):
        if x["id"] != y["id"]:
            errors.append(f"cue {x['id']}: index changed to {y['id']}")
        if x["timing"] != y["timing"]:
            errors.append(f"cue {x['id']}: timecode changed ({x['timing']} -> {y['timing']})")
    for i, c in enumerate(b):
        cid, dur = c["id"], c["end"] - c["start"]
        text = [l.strip() for l in c["lines"] if l.strip()]
        chars = sum(len(l) for l in text)          # characters incl. spaces, excl. line breaks
        if not text:
            errors.append(f"cue {cid}: empty text")
        if len(text) > MAX_LINES:
            errors.append(f"cue {cid}: {len(text)} lines (max {MAX_LINES})")
        for l in text:
            if len(l) > MAX_CHARS_PER_LINE:
                errors.append(f"cue {cid}: line has {len(l)} chars (max {MAX_CHARS_PER_LINE}): {l!r}")
        if dur > 0 and chars / dur > MAX_CPS:
            warnings.append(f"cue {cid}: reading speed {chars/dur:.1f} CPS (max {MAX_CPS:g}) - shorten the text, don't retime")
        if dur > MAX_DURATION:
            warnings.append(f"cue {cid}: duration {dur:.2f}s (max {MAX_DURATION:g}s)")
        if dur < MIN_DURATION - 1e-6:
            warnings.append(f"cue {cid}: duration {dur:.3f}s (min 5/6 s = {MIN_DURATION:.3f}s, {MIN_DURATION*fps:g} frames at {fps:g} fps)")
        for l in text:
            if "  " in l: warnings.append(f"cue {cid}: double space")
            if "\u2013" in l or "\u2014" in l: warnings.append(f"cue {cid}: en/em dash (not permitted)")
            if "..." in l: warnings.append(f"cue {cid}: three dots; use the single ellipsis character")
        if i + 1 < len(b):
            gap_frames = round((b[i+1]["start"] - c["end"]) * fps, 2)
            if gap_frames < MIN_GAP_FRAMES:
                warnings.append(f"cue {cid}->{b[i+1]['id']}: gap {gap_frames:g} frames (min {MIN_GAP_FRAMES})")
            elif fps == 24 and 3 <= gap_frames <= 11:
                warnings.append(f"cue {cid}->{b[i+1]['id']}: gap {gap_frames:g} frames (24 fps rule: gaps of 3-11 frames must be closed to 2; source timing, fix in a timing pass)")
    for e in errors: print("ERROR  ", e)
    for w in warnings: print("WARN   ", w)
    print(f"\n{len(b)} cues checked: {len(errors)} error(s), {len(warnings)} warning(s)")
    return 1 if errors else 0

if __name__ == "__main__":
    args = sys.argv[1:]
    fps = 24.0
    if "--fps" in args:
        k = args.index("--fps"); fps = float(args[k+1]); del args[k:k+2]
    if len(args) == 2 and args[0] == "to-json": to_json(args[1])
    elif len(args) == 3 and args[0] == "from-json": from_json(args[1], args[2])
    elif len(args) == 3 and args[0] == "check": sys.exit(check(args[1], args[2], fps))
    else: print(__doc__); sys.exit(2)
Enter fullscreen mode Exit fullscreen mode

The sample files

sample_ja.srt (cues 1 to 3 are a short team-written test text, cues 4 to 7 were written for this article):

1
00:00:01,000 --> 00:00:03,200
えっ、もうこんな時間?

2
00:00:03,400 --> 00:00:06,000
ごめん、先に行ってて。すぐ追いつくから!

3
00:00:06,300 --> 00:00:09,000
……まあ、いっか。なんとかなるでしょ。

4
00:00:10,000 --> 00:00:12,400
駅前の新しいカフェ、

5
00:00:12,480 --> 00:00:15,000
昨日オープンしたって聞いたんだけど

6
00:00:15,084 --> 00:00:17,500
行ってみない?

7
00:00:18,200 --> 00:00:19,000
うん!
Enter fullscreen mode Exit fullscreen mode

naive_en.srt, used for the failing run:

1
00:00:01,000 --> 00:00:03,200
Eh, is it already this time?

2
00:00:03,400 --> 00:00:06,000
I'm sorry, please go ahead first and I will catch up with you right away!

3
00:00:06,300 --> 00:00:09,000
...Well, it's fine. It will work out somehow.

4
00:00:10,000 --> 00:00:12,400
The new café in front of the station,

5
00:00:12,480 --> 00:00:15,000
I heard it opened yesterday, but

6
00:00:15,084 --> 00:00:19,000
won't you go? Yes!
Enter fullscreen mode Exit fullscreen mode

Sources (checked October 20, 2026; Netflix pages re-opened by the editor): Netflix Partner Help Center, "English (USA) Timed Text Style Guide" (https://partnerhelp.netflixstudios.com/hc/en-us/articles/217350977-English-USA-Timed-Text-Style-Guide), "Timed Text Style Guide: General Requirements" (https://partnerhelp.netflixstudios.com/hc/en-us/articles/215758617-Timed-Text-Style-Guide-General-Requirements), and "Timed Text Style Guide: Subtitle Timing Guidelines" (https://partnerhelp.netflixstudios.com/hc/en-us/articles/360051554394-Timed-Text-Style-Guide-Subtitle-Timing-Guidelines).

Top comments (0)