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, durati

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.
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:
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.
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.
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.
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. \n I'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:
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.
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.
The checker can tell you a line fits. It can't tell you it's right. Have someone who reads Japanese check:
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:. 1 f } CPS (max { MAX_CPS: g } ) - shorten the text, don ' t retime " ) if dur > MAX_DURATION: warnings. append ( f " cue { cid }: duration { dur:.
2 f } s (max { MAX_DURATION: g } s) " ) if dur < MIN_DURATION - 1e-6: warnings. append ( f " cue { cid }: duration { dur:. 3 f } s (min 5/6 s = { MIN_DURATION:. 3 f } 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 ).