OpenAI Codex v0.162.1
--------
workdir: <run:review-codex-on-claude>
model: gpt-6.1-sol
provider: openai
approval: never
sandbox: read-only
reasoning effort: none
reasoning summaries: none
session id: <removed>
--------
user
current changes
exec
/bin/zsh -lc 'pwd; git status --short; ls -a; find .. -name AGENTS.md -o -name AGENTS.override.md' in <run:review-codex-on-claude>
 succeeded in 0ms:
<run:review-codex-on-claude>
 M README.md
 M srtkit/cli.py
 M srtkit/timecode.py
 M srtkit/transform.py
 A srtkit/vtt.py
 M tests/test_cli.py
 A tests/test_vtt.py
.
..
.git
.gitignore
README.md
samples
srtkit
tests

exec
/bin/zsh -lc 'git diff HEAD; ls <tmp-path> <run:AGENTS>* 2>/dev/null; cat srtkit/*.py' in <run:review-codex-on-claude>
 succeeded in 0ms:
diff --git a/README.md b/README.md
index 57bead0..2fdfa29 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,45 @@
 # srtkit
 
-A small command-line toolkit for SubRip (`.srt`) subtitle files.
+A small command-line toolkit for SubRip (`.srt`) and WebVTT (`.vtt`) subtitle files.
 
 ```
 python -m srtkit shift <file> <offset-ms>   # move every cue by an offset (negative allowed)
 python -m srtkit stats <file>               # cue count, total duration, longest line
+python -m srtkit convert <input> <output>   # convert SRT to WebVTT or WebVTT to SRT
 ```
 
-Run the tests with:
+`shift` and `stats` read `.vtt` files as WebVTT and anything else as SRT.
+`shift` writes its result to stdout in the same format as the input; for WebVTT
+it keeps the header, `STYLE`/`REGION` blocks, cue identifiers and cue settings,
+and also moves inline karaoke timestamps such as `<00:00:01.500>`.
+
+## convert
+
+```
+python -m srtkit convert talk.srt talk.vtt   # SRT -> WebVTT
+python -m srtkit convert talk.vtt talk.srt   # WebVTT -> SRT
+```
+
+The direction comes from the file extensions (case-insensitive): one file must
+end in `.srt` and the other in `.vtt`. The output file is overwritten if it exists.
+
+WebVTT input may use any of the features found in real files: a BOM, CRLF or CR
+line endings, text and metadata after `WEBVTT` (e.g. `Kind:`/`Language:`),
+`NOTE`, `STYLE` and `REGION` blocks, cue identifiers, timestamps without hours
+(`01:02.500`), cue settings (`align:start position:10%`), and cues missing the
+blank line between them.
+
+What changes in each direction, since the formats don't map one-to-one:
+
+- **WebVTT → SRT:** cues are numbered from 1. `<b>`, `<i>` and `<u>` are kept
+  (without classes); voice (`<v Name>`), class (`<c>`), language, ruby
+  annotation and inline timestamp tags are removed, keeping their text.
+  Character references like `&amp;` are decoded. Cue identifiers, cue
+  settings, comments and style blocks are dropped.
+- **SRT → WebVTT:** `<b>`, `<i>` and `<u>` are kept; `<font>` tags and
+  `{\an8}`-style overrides are dropped; `&`, `<` and `>` in the text are escaped.
+
+## Tests
 
 ```
 python -m unittest discover -s tests
diff --git a/srtkit/cli.py b/srtkit/cli.py
index 78b2e2d..79f31da 100644
--- a/srtkit/cli.py
+++ b/srtkit/cli.py
@@ -1,15 +1,19 @@
 """Command-line entry point: python -m srtkit <command> ..."""
 
 import argparse
+import os
 import sys
 
-from .parser import format_srt, read_srt
+from .parser import format_srt, parse_srt
 from .stats import summarize
 from .transform import renumber, shift
+from .vtt import format_vtt, parse_vtt_document, shift_inline_timestamps, srt_to_vtt_cues, vtt_to_srt_cues
+
+FORMATS = {".srt": "srt", ".vtt": "vtt"}
 
 
 def build_parser():
-    parser = argparse.ArgumentParser(prog="srtkit", description="Tools for SRT subtitle files.")
+    parser = argparse.ArgumentParser(prog="srtkit", description="Tools for SRT and WebVTT subtitle files.")
     sub = parser.add_subparsers(dest="command", required=True)
 
     p_shift = sub.add_parser("shift", help="move every cue by an offset in milliseconds")
@@ -18,19 +22,71 @@ def build_parser():
 
     p_stats = sub.add_parser("stats", help="print cue count, total duration and longest line")
     p_stats.add_argument("file")
+
+    p_convert = sub.add_parser("convert", help="convert between SRT and WebVTT based on the file extensions")
+    p_convert.add_argument("input", help="input file (.srt or .vtt)")
+    p_convert.add_argument("output", help="output file (.vtt or .srt)")
     return parser
 
 
+def format_of(path):
+    """Return "srt" or "vtt" from the file extension, or None if it is neither."""
+    return FORMATS.get(os.path.splitext(path)[1].lower())
+
+
+def read_text(path):
+    with open(path, encoding="utf-8-sig") as handle:
+        return handle.read()
+
+
 def main(argv=None):
-    args = build_parser().parse_args(argv)
-    cues = read_srt(args.file)
-    if args.command == "shift":
-        sys.stdout.write(format_srt(renumber(shift(cues, args.offset))))
-        return 0
-    if args.command == "stats":
-        summary = summarize(cues)
-        print(f"cues: {summary['cues']}")
-        print(f"duration: {summary['duration']}")
-        print(f"longest line: {summary['longest_line']} chars")
-        return 0
+    parser = build_parser()
+    args = parser.parse_args(argv)
+    try:
+        if args.command == "convert":
+            return convert(parser, args.input, args.output)
+        # shift and stats treat anything that is not .vtt as SRT, as before.
+        is_vtt = format_of(args.file) == "vtt"
+        text = read_text(args.file)
+        if is_vtt:
+            header, preamble, cues = parse_vtt_document(text)
+        else:
+            cues = parse_srt(text)
+        if args.command == "shift":
+            shifted = renumber(shift(cues, args.offset))
+            if is_vtt:
+                shifted = shift_inline_timestamps(shifted, args.offset)
+                sys.stdout.write(format_vtt(shifted, header, preamble))
+            else:
+                sys.stdout.write(format_srt(shifted))
+            return 0
+        if args.command == "stats":
+            # Measure WebVTT lines without voice/class/timestamp tags so they
+            # compare like-for-like with SRT text.
+            summary = summarize(vtt_to_srt_cues(cues) if is_vtt else cues)
+            print(f"cues: {summary['cues']}")
+            print(f"duration: {summary['duration']}")
+            print(f"longest line: {summary['longest_line']} chars")
+            return 0
+    except (OSError, ValueError) as exc:
+        print(f"srtkit: error: {exc}", file=sys.stderr)
+        return 1
     return 2
+
+
+def convert(parser, input_path, output_path):
+    source, target = format_of(input_path), format_of(output_path)
+    for path, fmt in ((input_path, source), (output_path, target)):
+        if fmt is None:
+            parser.error(f"cannot tell the format of {path!r}: use a .srt or .vtt extension")
+    if source == target:
+        parser.error(f"input and output are both {source.upper()}; convert needs one .srt and one .vtt file")
+
+    text = read_text(input_path)
+    if source == "srt":
+        result = format_vtt(srt_to_vtt_cues(parse_srt(text)))
+    else:
+        result = format_srt(vtt_to_srt_cues(parse_vtt_document(text)[2]))
+    with open(output_path, "w", encoding="utf-8", newline="\n") as handle:
+        handle.write(result)
+    return 0
diff --git a/srtkit/timecode.py b/srtkit/timecode.py
index ed94b67..9ff751b 100644
--- a/srtkit/timecode.py
+++ b/srtkit/timecode.py
@@ -1,4 +1,4 @@
-"""Conversion between SRT timestamps (HH:MM:SS,mmm) and integer milliseconds."""
+"""Conversion between SRT/WebVTT timestamps and integer milliseconds."""
 
 import re
 
@@ -24,3 +24,25 @@ def format_timestamp(ms):
     seconds = (ms // 1000) % 60
     millis = ms % 1000
     return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"
+
+
+# WebVTT timestamps use a full stop before the milliseconds and may omit the
+# hours field entirely (MM:SS.mmm). A comma is accepted as well because some
+# tools emit SRT-style separators in .vtt files.
+_VTT_TIMESTAMP = re.compile(r"^(?:(\d{2,}):)?(\d{2}):(\d{2})[.,](\d{3})$")
+
+
+def parse_vtt_timestamp(text):
+    """Return the number of milliseconds represented by a WebVTT timestamp."""
+    match = _VTT_TIMESTAMP.match(text.strip())
+    if not match:
+        raise ValueError(f"not a WebVTT timestamp: {text!r}")
+    hours, minutes, seconds, millis = (int(part or 0) for part in match.groups())
+    if minutes > 59 or seconds > 59:
+        raise ValueError(f"minutes and seconds must be below 60: {text!r}")
+    return ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis
+
+
+def format_vtt_timestamp(ms):
+    """Format milliseconds as a WebVTT timestamp (HH:MM:SS.mmm). Negative values clamp to zero."""
+    return format_timestamp(ms).replace(",", ".")
diff --git a/srtkit/transform.py b/srtkit/transform.py
index b245ffc..7229b28 100644
--- a/srtkit/transform.py
+++ b/srtkit/transform.py
@@ -1,4 +1,8 @@
-"""Operations that change cue timing."""
+"""Operations that change cue timing.
+
+Cues may carry extra fields after (index, start_ms, end_ms, lines) -- WebVTT
+cues add an identifier and cue settings -- and those are passed through as-is.
+"""
 
 
 def shift(cues, offset_ms):
@@ -7,10 +11,10 @@ def shift(cues, offset_ms):
     for cue in cues:
         start = max(0, cue[1] + offset_ms)
         end = max(start, cue[2] + offset_ms)
-        shifted.append((cue[0], start, end, list(cue[3])))
+        shifted.append((cue[0], start, end, list(cue[3])) + tuple(cue[4:]))
     return shifted
 
 
 def renumber(cues):
     """Return the cues numbered from 1 in their current order."""
-    return [(i, cue[1], cue[2], list(cue[3])) for i, cue in enumerate(cues, start=1)]
+    return [(i, cue[1], cue[2], list(cue[3])) + tuple(cue[4:]) for i, cue in enumerate(cues, start=1)]
diff --git a/srtkit/vtt.py b/srtkit/vtt.py
new file mode 100644
index 0000000..9def6c9
--- /dev/null
+++ b/srtkit/vtt.py
@@ -0,0 +1,208 @@
+"""Parse and serialise WebVTT files, and convert cue text to and from SRT.
+
+A WebVTT cue is represented like an SRT cue with two extra fields:
+(index, start_ms, end_ms, lines, identifier, settings). identifier is the
+optional cue identifier line (or None) and settings is the raw cue settings
+string that follows the end timestamp (or "").
+
+The parser follows the WebVTT spec where it matters for real files: an
+optional BOM, CR/CRLF/LF line endings, header text and metadata after
+"WEBVTT", NOTE/STYLE/REGION blocks, optional cue identifiers, timestamps
+without hours, cue settings, and cues not separated by a blank line.
+"""
+
+import html
+import re
+
+from .timecode import format_vtt_timestamp, parse_vtt_timestamp
+
+
+def _block_keyword(line, keyword):
+    return line == keyword or line.startswith(keyword + " ") or line.startswith(keyword + "\t")
+
+
+def _split_lines(text):
+    if text.startswith("﻿"):
+        text = text[1:]
+    return text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
+
+
+def _parse_timing(line):
+    start_text, _, rest = line.partition("-->")
+    parts = rest.split()
+    if not parts:
+        raise ValueError(f"missing end timestamp: {line!r}")
+    start = parse_vtt_timestamp(start_text)
+    end = parse_vtt_timestamp(parts[0])
+    return start, end, " ".join(parts[1:])
+
+
+def parse_vtt_document(text):
+    """Parse WebVTT text into (header_lines, preamble_blocks, cues).
+
+    header_lines holds the "WEBVTT" line and any header text after it;
+    preamble_blocks holds STYLE and REGION blocks (each a list of lines),
+    which have to be kept before the first cue. NOTE comments are dropped.
+    """
+    lines = _split_lines(text)
+    first = lines[0]
+    if not _block_keyword(first, "WEBVTT"):
+        raise ValueError("not a WebVTT file: missing 'WEBVTT' header")
+
+    i = 1
+    header = [first]
+    while i < len(lines) and lines[i].strip() and "-->" not in lines[i]:
+        header.append(lines[i])
+        i += 1
+
+    preamble = []
+    cues = []
+    while i < len(lines):
+        if not lines[i].strip():
+            i += 1
+            continue
+        block = []
+        while i < len(lines) and lines[i].strip():
+            block.append(lines[i])
+            i += 1
+
+        timing_rows = [n for n, row in enumerate(block) if "-->" in row]
+        if not timing_rows:
+            if not cues and (_block_keyword(block[0], "STYLE") or _block_keyword(block[0], "REGION")):
+                preamble.append(block)
+            # NOTE comments, and anything else without a timing line, are not cues.
+            continue
+        if _block_keyword(block[0], "NOTE"):
+            continue
+
+        # A block normally holds one cue, but a line containing "-->" always
+        # starts a new cue, so cues missing their blank-line separator still parse.
+        first_timing = timing_rows[0]
+        identifier = block[first_timing - 1] if first_timing > 0 else None
+        bounds = timing_rows + [len(block)]
+        for n, row in enumerate(timing_rows):
+            start, end, settings = _parse_timing(block[row])
+            cue_lines = block[row + 1 : bounds[n + 1]]
+            cues.append((len(cues) + 1, start, end, cue_lines, identifier if n == 0 else None, settings))
+    return header, preamble, cues
+
+
+def parse_vtt(text):
+    """Parse WebVTT text into a list of cue tuples."""
+    return parse_vtt_document(text)[2]
+
+
+def format_vtt(cues, header=("WEBVTT",), preamble=()):
+    """Serialise cue tuples as WebVTT text. Plain SRT-shaped cues are accepted too."""
+    out = list(header)
+    out.append("")
+    for block in preamble:
+        out.extend(block)
+        out.append("")
+    for cue in cues:
+        identifier = cue[4] if len(cue) > 4 else None
+        settings = cue[5] if len(cue) > 5 else ""
+        if identifier:
+            out.append(identifier)
+        timing = f"{format_vtt_timestamp(cue[1])} --> {format_vtt_timestamp(cue[2])}"
+        out.append(f"{timing} {settings}" if settings else timing)
+        out.extend(cue[3])
+        out.append("")
+    return "\n".join(out)
+
+
+def read_vtt_document(path):
+    with open(path, encoding="utf-8-sig") as handle:
+        return parse_vtt_document(handle.read())
+
+
+def read_vtt(path):
+    return read_vtt_document(path)[2]
+
+
+# --- inline timestamps -----------------------------------------------------
+
+_INLINE_TIMESTAMP = re.compile(r"<((?:\d{2,}:)?\d{2}:\d{2}\.\d{3})>")
+
+
+def shift_inline_timestamps(cues, offset_ms):
+    """Move karaoke-style inline timestamps (<00:00:01.500>) in cue text by offset_ms."""
+
+    def move(match):
+        return f"<{format_vtt_timestamp(parse_vtt_timestamp(match.group(1)) + offset_ms)}>"
+
+    return [
+        (cue[0], cue[1], cue[2], [_INLINE_TIMESTAMP.sub(move, line) for line in cue[3]]) + tuple(cue[4:])
+        for cue in cues
+    ]
+
+
+# --- cue text conversion ---------------------------------------------------
+
+_SIMPLE_STYLES = ("b", "i", "u")
+
+# Ruby annotations (<rt>...</rt>; the end tag may be omitted before </ruby>)
+# have no SRT equivalent and would otherwise run into the base text.
+_VTT_RUBY_TEXT = re.compile(r"<rt\b[^>]*>.*?(?:</rt>|(?=</ruby>)|$)")
+_VTT_TAG = re.compile(r"<(/?)([^>\s.]*)[^>]*>")
+
+
+def vtt_text_to_srt(line):
+    """Convert one line of WebVTT cue text to SRT text.
+
+    <b>, <i> and <u> are kept (minus any classes); voice, class, language,
+    ruby and timestamp tags are removed; character references are decoded.
+    """
+
+    def tag(match):
+        closing, name = match.group(1), match.group(2).lower()
+        return f"<{closing}{name}>" if name in _SIMPLE_STYLES else ""
+
+    line = _VTT_RUBY_TEXT.sub("", line)
+    line = _VTT_TAG.sub(tag, line)
+    return html.unescape(line)
+
+
+_SRT_STYLE_TAG = re.compile(r"<\s*(/?)\s*([biu])\s*>", re.IGNORECASE)
+_SRT_FONT_TAG = re.compile(r"<\s*/?\s*font(?:\s[^>]*)?>", re.IGNORECASE)
+_SRT_ASS_OVERRIDE = re.compile(r"\{\\[^}]*\}")
+
+
+def srt_text_to_vtt(line):
+    """Convert one line of SRT text to WebVTT cue text.
+
+    <b>, <i> and <u> are kept; <font> tags and {\\an8}-style overrides, which
+    WebVTT has no equivalent for, are dropped; everything else is escaped so
+    that "&", "<" and "-->" in dialogue survive.
+    """
+    line = _SRT_FONT_TAG.sub("", line)
+    line = _SRT_ASS_OVERRIDE.sub("", line)
+    out = []
+    pos = 0
+    for match in _SRT_STYLE_TAG.finditer(line):
+        out.append(_escape(line[pos : match.start()]))
+        out.append(f"<{match.group(1)}{match.group(2).lower()}>")
+        pos = match.end()
+    out.append(_escape(line[pos:]))
+    return "".join(out)
+
+
+def _escape(text):
+    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
+
+
+def _convert_lines(lines, convert):
+    # A line that converts to nothing (e.g. it held only tags) is dropped,
+    # since an empty line would end the cue early in either format.
+    converted = (convert(line) for line in lines)
+    return [line for line in converted if line.strip()]
+
+
+def vtt_to_srt_cues(cues):
+    """Turn WebVTT cues into SRT cues numbered from 1."""
+    return [(i, cue[1], cue[2], _convert_lines(cue[3], vtt_text_to_srt)) for i, cue in enumerate(cues, start=1)]
+
+
+def srt_to_vtt_cues(cues):
+    """Turn SRT cues into WebVTT cues (no identifiers or settings)."""
+    return [(i, cue[1], cue[2], _convert_lines(cue[3], srt_text_to_vtt), None, "") for i, cue in enumerate(cues, start=1)]
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a2c4c7b..133d8f5 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,13 +1,22 @@
 import io
 import os
+import tempfile
 import unittest
-from contextlib import redirect_stdout
+from contextlib import redirect_stderr, redirect_stdout
 
 from srtkit.cli import main
 
 SAMPLE = os.path.join(os.path.dirname(__file__), "..", "samples", "interview.srt")
 
 
+VTT = (
+    "WEBVTT\n\n"
+    "NOTE produced by a caption editor\n\n"
+    "a\n00:01.000 --> 00:03.200 align:start\n<v Host>Thanks for coming in today.</v>\n\n"
+    "b\n00:03.400 --> 00:06.900\nLet's start &amp; go.\n"
+)
+
+
 def run(*argv):
     buf = io.StringIO()
     with redirect_stdout(buf):
@@ -28,5 +37,88 @@ class CliTests(unittest.TestCase):
         self.assertTrue(out.startswith("1\n00:00:00,000 --> 00:00:01,700\n"))
 
 
+class VttCliTests(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.addCleanup(self.tmp.cleanup)
+
+    def path(self, name, content=None):
+        path = os.path.join(self.tmp.name, name)
+        if content is not None:
+            with open(path, "w", encoding="utf-8") as handle:
+                handle.write(content)
+        return path
+
+    def read(self, path):
+        with open(path, encoding="utf-8") as handle:
+            return handle.read()
+
+    def test_convert_srt_to_vtt(self):
+        out = self.path("out.vtt")
+        code, _ = run("convert", SAMPLE, out)
+        self.assertEqual(code, 0)
+        text = self.read(out)
+        self.assertTrue(text.startswith("WEBVTT\n\n00:00:01.000 --> 00:00:03.200\nThanks for coming in today.\n\n"))
+        code, stats = run("stats", out)
+        self.assertEqual(code, 0)
+        self.assertIn("cues: 6", stats)
+        self.assertIn("longest line: 79 chars", stats)
+
+    def test_convert_vtt_to_srt(self):
+        out = self.path("out.srt")
+        code, _ = run("convert", self.path("in.vtt", VTT), out)
+        self.assertEqual(code, 0)
+        self.assertEqual(
+            self.read(out),
+            "1\n00:00:01,000 --> 00:00:03,200\nThanks for coming in today.\n\n"
+            "2\n00:00:03,400 --> 00:00:06,900\nLet's start & go.\n",
+        )
+
+    def test_convert_uppercase_extensions(self):
+        out = self.path("OUT.SRT")
+        code, _ = run("convert", self.path("IN.VTT", VTT), out)
+        self.assertEqual(code, 0)
+        self.assertTrue(self.read(out).startswith("1\n"))
+
+    def test_convert_round_trip(self):
+        vtt, srt = self.path("a.vtt"), self.path("b.srt")
+        run("convert", SAMPLE, vtt)
+        run("convert", vtt, srt)
+        self.assertEqual(self.read(srt), self.read(SAMPLE))
+
+    def test_convert_rejects_unknown_extension(self):
+        with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+            run("convert", SAMPLE, self.path("out.txt"))
+
+    def test_convert_rejects_same_format(self):
+        with redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+            run("convert", SAMPLE, self.path("copy.srt"))
+
+    def test_convert_reports_bad_input(self):
+        err = io.StringIO()
+        with redirect_stderr(err):
+            code, _ = run("convert", self.path("bad.vtt", "not a caption file\n"), self.path("out.srt"))
+        self.assertEqual(code, 1)
+        self.assertIn("WEBVTT", err.getvalue())
+
+    def test_stats_vtt(self):
+        code, out = run("stats", self.path("in.vtt", VTT))
+        self.assertEqual(code, 0)
+        self.assertIn("cues: 2", out)
+        self.assertIn("duration: 00:00:05,700", out)
+        # The <v Host> tag is not counted.
+        self.assertIn("longest line: 27 chars", out)
+
+    def test_shift_vtt_stays_vtt(self):
+        code, out = run("shift", self.path("in.vtt", VTT), "-1500")
+        self.assertEqual(code, 0)
+        self.assertEqual(
+            out,
+            "WEBVTT\n\n"
+            "a\n00:00:00.000 --> 00:00:01.700 align:start\n<v Host>Thanks for coming in today.</v>\n\n"
+            "b\n00:00:01.900 --> 00:00:05.400\nLet's start &amp; go.\n",
+        )
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_vtt.py b/tests/test_vtt.py
new file mode 100644
index 0000000..7b3c931
--- /dev/null
+++ b/tests/test_vtt.py
@@ -0,0 +1,180 @@
+import unittest
+
+from srtkit.parser import format_srt, parse_srt
+from srtkit.timecode import format_vtt_timestamp, parse_vtt_timestamp
+from srtkit.vtt import (
+    format_vtt,
+    parse_vtt,
+    parse_vtt_document,
+    shift_inline_timestamps,
+    srt_text_to_vtt,
+    srt_to_vtt_cues,
+    vtt_text_to_srt,
+    vtt_to_srt_cues,
+)
+
+# Modelled on files produced by YouTube, browsers' caption editors and
+# hand-written examples from the spec: BOM, CRLF, header metadata, STYLE and
+# NOTE blocks, identifiers, cue settings, short timestamps, and two cues with
+# no blank line between them.
+REAL_WORLD = (
+    "﻿WEBVTT - Interview\r\n"
+    "Kind: captions\r\n"
+    "Language: en\r\n"
+    "\r\n"
+    "STYLE\r\n"
+    "::cue { color: yellow }\r\n"
+    "\r\n"
+    "NOTE This comment\r\n"
+    "spans two lines\r\n"
+    "\r\n"
+    "intro\r\n"
+    "00:01.000 --> 00:03.200 align:start position:10%\r\n"
+    "<v Host>Thanks &amp; welcome</v>\r\n"
+    "<c.yellow><i>today</i></c>\r\n"
+    "00:03.400 --> 00:06.900\r\n"
+    "<00:03.400><c>karaoke</c><00:04.000><c> words</c>\r\n"
+    "\r\n"
+    "\r\n"
+    "NOTE trailing comment\r\n"
+    "\r\n"
+    "01:00:00.000-->01:00:01.000\r\n"
+    "1 &lt; 2\r\n"
+)
+
+
+class VttTimestampTests(unittest.TestCase):
+    def test_with_hours(self):
+        self.assertEqual(parse_vtt_timestamp("01:02:05.500"), 3_725_500)
+
+    def test_without_hours(self):
+        self.assertEqual(parse_vtt_timestamp("02:05.500"), 125_500)
+
+    def test_long_hours(self):
+        self.assertEqual(parse_vtt_timestamp("100:00:00.000"), 360_000_000)
+
+    def test_rejects_bad_seconds(self):
+        with self.assertRaises(ValueError):
+            parse_vtt_timestamp("00:60.000")
+
+    def test_rejects_garbage(self):
+        with self.assertRaises(ValueError):
+            parse_vtt_timestamp("1.5")
+
+    def test_format(self):
+        self.assertEqual(format_vtt_timestamp(125_500), "00:02:05.500")
+
+
+class VttParserTests(unittest.TestCase):
+    def test_real_world_document(self):
+        header, preamble, cues = parse_vtt_document(REAL_WORLD)
+        self.assertEqual(header, ["WEBVTT - Interview", "Kind: captions", "Language: en"])
+        self.assertEqual(preamble, [["STYLE", "::cue { color: yellow }"]])
+        self.assertEqual(
+            cues,
+            [
+                (1, 1000, 3200, ["<v Host>Thanks &amp; welcome</v>", "<c.yellow><i>today</i></c>"], "intro", "align:start position:10%"),
+                (2, 3400, 6900, ["<00:03.400><c>karaoke</c><00:04.000><c> words</c>"], None, ""),
+                (3, 3_600_000, 3_601_000, ["1 &lt; 2"], None, ""),
+            ],
+        )
+
+    def test_minimal(self):
+        self.assertEqual(parse_vtt("WEBVTT\n\n00:01.000 --> 00:02.000\nHi\n"), [(1, 1000, 2000, ["Hi"], None, "")])
+
+    def test_header_only(self):
+        self.assertEqual(parse_vtt("WEBVTT\n"), [])
+
+    def test_cr_only_line_endings(self):
+        self.assertEqual(parse_vtt("WEBVTT\r\r00:01.000 --> 00:02.000\rHi\r"), [(1, 1000, 2000, ["Hi"], None, "")])
+
+    def test_cue_directly_after_header(self):
+        self.assertEqual(parse_vtt("WEBVTT\n00:01.000 --> 00:02.000\nHi"), [(1, 1000, 2000, ["Hi"], None, "")])
+
+    def test_numeric_identifiers(self):
+        cues = parse_vtt("WEBVTT\n\n1\n00:01.000 --> 00:02.000\nHi\n\n2\n00:03.000 --> 00:04.000\nBye\n")
+        self.assertEqual([cue[4] for cue in cues], ["1", "2"])
+        self.assertEqual([cue[3] for cue in cues], [["Hi"], ["Bye"]])
+
+    def test_style_after_cues_is_ignored(self):
+        header, preamble, cues = parse_vtt_document("WEBVTT\n\n00:01.000 --> 00:02.000\nHi\n\nSTYLE\n::cue {}\n")
+        self.assertEqual(preamble, [])
+        self.assertEqual(len(cues), 1)
+
+    def test_missing_header(self):
+        with self.assertRaises(ValueError):
+            parse_vtt("1\n00:00:01,000 --> 00:00:02,000\nHi\n")
+
+    def test_header_must_be_exact_word(self):
+        with self.assertRaises(ValueError):
+            parse_vtt("WEBVTTX\n")
+
+    def test_bad_timestamp(self):
+        with self.assertRaises(ValueError):
+            parse_vtt("WEBVTT\n\n00:01 --> 00:02.000\nHi\n")
+
+    def test_format_round_trip(self):
+        text = (
+            "WEBVTT - Interview\n\nSTYLE\n::cue {}\n\n"
+            "intro\n00:00:01.000 --> 00:00:02.000 line:0\nHi\n\n"
+            "00:00:03.000 --> 00:00:04.000\nBye\n"
+        )
+        header, preamble, cues = parse_vtt_document(text)
+        self.assertEqual(format_vtt(cues, header, preamble), text)
+
+
+class VttTextConversionTests(unittest.TestCase):
+    def test_vtt_to_srt_keeps_basic_styles(self):
+        self.assertEqual(vtt_text_to_srt("<b.loud>Hey</b> <i>you</i> <u>there</u>"), "<b>Hey</b> <i>you</i> <u>there</u>")
+
+    def test_vtt_to_srt_strips_other_tags(self):
+        self.assertEqual(vtt_text_to_srt("<v.first Bob Smith>Hi <lang fr>ami</lang></v>"), "Hi ami")
+        self.assertEqual(vtt_text_to_srt("<00:00:01.000><c>one</c><00:00:02.000><c> two</c>"), "one two")
+
+    def test_vtt_to_srt_drops_ruby_text(self):
+        self.assertEqual(vtt_text_to_srt("<ruby>漢<rt>kan</rt>字<rt>ji</ruby>"), "漢字")
+
+    def test_vtt_to_srt_decodes_entities(self):
+        self.assertEqual(vtt_text_to_srt("Tom &amp; Jerry &lt;3 &gt; &lrm;x&nbsp;y"), "Tom & Jerry <3 > ‎x y")
+
+    def test_srt_to_vtt_keeps_basic_styles(self):
+        self.assertEqual(srt_text_to_vtt("<I>Hi</I> < b >there</b>"), "<i>Hi</i> <b>there</b>")
+
+    def test_srt_to_vtt_escapes_markup(self):
+        self.assertEqual(srt_text_to_vtt("Tom & Jerry <3 --> me"), "Tom &amp; Jerry &lt;3 --&gt; me")
+
+    def test_srt_to_vtt_drops_font_and_overrides(self):
+        self.assertEqual(srt_text_to_vtt('{\\an8}<font color="#ff0000">Red</font>'), "Red")
+
+    def test_lines_that_become_empty_are_dropped(self):
+        cues = vtt_to_srt_cues([(1, 0, 1000, ["<c></c>", "Text"], None, "")])
+        self.assertEqual(cues, [(1, 0, 1000, ["Text"])])
+
+    def test_vtt_to_srt_document(self):
+        srt = format_srt(vtt_to_srt_cues(parse_vtt(REAL_WORLD)))
+        self.assertEqual(
+            srt,
+            "1\n00:00:01,000 --> 00:00:03,200\nThanks & welcome\n<i>today</i>\n\n"
+            "2\n00:00:03,400 --> 00:00:06,900\nkaraoke words\n\n"
+            "3\n01:00:00,000 --> 01:00:01,000\n1 < 2\n",
+        )
+        self.assertEqual(len(parse_srt(srt)), 3)
+
+    def test_srt_to_vtt_round_trip(self):
+        srt = "1\n00:00:01,000 --> 00:00:02,000\n<i>Tom & Jerry</i>\n\n2\n00:00:03,000 --> 00:00:04,500\nBye\n"
+        vtt = format_vtt(srt_to_vtt_cues(parse_srt(srt)))
+        self.assertEqual(
+            vtt,
+            "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n<i>Tom &amp; Jerry</i>\n\n00:00:03.000 --> 00:00:04.500\nBye\n",
+        )
+        self.assertEqual(format_srt(vtt_to_srt_cues(parse_vtt(vtt))), srt)
+
+
+class InlineTimestampTests(unittest.TestCase):
+    def test_shift(self):
+        cues = [(1, 1000, 3000, ["<00:01.500>a <00:00:02.000>b"], None, "")]
+        self.assertEqual(shift_inline_timestamps(cues, -1000)[0][3], ["<00:00:00.500>a <00:00:01.000>b"])
+
+
+if __name__ == "__main__":
+    unittest.main()
zsh:1: no matches found: <tmp-path>
"""srtkit: small utilities for SubRip subtitle files."""

__version__ = "0.3.0"
import sys

from .cli import main

sys.exit(main())
"""Command-line entry point: python -m srtkit <command> ..."""

import argparse
import os
import sys

from .parser import format_srt, parse_srt
from .stats import summarize
from .transform import renumber, shift
from .vtt import format_vtt, parse_vtt_document, shift_inline_timestamps, srt_to_vtt_cues, vtt_to_srt_cues

FORMATS = {".srt": "srt", ".vtt": "vtt"}


def build_parser():
    parser = argparse.ArgumentParser(prog="srtkit", description="Tools for SRT and WebVTT subtitle files.")
    sub = parser.add_subparsers(dest="command", required=True)

    p_shift = sub.add_parser("shift", help="move every cue by an offset in milliseconds")
    p_shift.add_argument("file")
    p_shift.add_argument("offset", type=int, help="offset in ms; negative moves earlier")

    p_stats = sub.add_parser("stats", help="print cue count, total duration and longest line")
    p_stats.add_argument("file")

    p_convert = sub.add_parser("convert", help="convert between SRT and WebVTT based on the file extensions")
    p_convert.add_argument("input", help="input file (.srt or .vtt)")
    p_convert.add_argument("output", help="output file (.vtt or .srt)")
    return parser


def format_of(path):
    """Return "srt" or "vtt" from the file extension, or None if it is neither."""
    return FORMATS.get(os.path.splitext(path)[1].lower())


def read_text(path):
    with open(path, encoding="utf-8-sig") as handle:
        return handle.read()


def main(argv=None):
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        if args.command == "convert":
            return convert(parser, args.input, args.output)
        # shift and stats treat anything that is not .vtt as SRT, as before.
        is_vtt = format_of(args.file) == "vtt"
        text = read_text(args.file)
        if is_vtt:
            header, preamble, cues = parse_vtt_document(text)
        else:
            cues = parse_srt(text)
        if args.command == "shift":
            shifted = renumber(shift(cues, args.offset))
            if is_vtt:
                shifted = shift_inline_timestamps(shifted, args.offset)
                sys.stdout.write(format_vtt(shifted, header, preamble))
            else:
                sys.stdout.write(format_srt(shifted))
            return 0
        if args.command == "stats":
            # Measure WebVTT lines without voice/class/timestamp tags so they
            # compare like-for-like with SRT text.
            summary = summarize(vtt_to_srt_cues(cues) if is_vtt else cues)
            print(f"cues: {summary['cues']}")
            print(f"duration: {summary['duration']}")
            print(f"longest line: {summary['longest_line']} chars")
            return 0
    except (OSError, ValueError) as exc:
        print(f"srtkit: error: {exc}", file=sys.stderr)
        return 1
    return 2


def convert(parser, input_path, output_path):
    source, target = format_of(input_path), format_of(output_path)
    for path, fmt in ((input_path, source), (output_path, target)):
        if fmt is None:
            parser.error(f"cannot tell the format of {path!r}: use a .srt or .vtt extension")
    if source == target:
        parser.error(f"input and output are both {source.upper()}; convert needs one .srt and one .vtt file")

    text = read_text(input_path)
    if source == "srt":
        result = format_vtt(srt_to_vtt_cues(parse_srt(text)))
    else:
        result = format_srt(vtt_to_srt_cues(parse_vtt_document(text)[2]))
    with open(output_path, "w", encoding="utf-8", newline="\n") as handle:
        handle.write(result)
    return 0
"""Parse and serialise SRT files.

A cue is represented as a tuple: (index, start_ms, end_ms, lines), where
lines is a list of strings.
"""

from .timecode import format_timestamp, parse_timestamp


def parse_srt(text):
    """Parse SRT text into a list of cue tuples."""
    cues = []
    blocks = [b for b in text.replace("\r\n", "\n").strip().split("\n\n") if b.strip()]
    for block in blocks:
        rows = block.strip().split("\n")
        if len(rows) < 2:
            raise ValueError(f"incomplete cue: {block!r}")
        index = int(rows[0].strip())
        start_text, sep, end_text = rows[1].partition("-->")
        if not sep:
            raise ValueError(f"missing '-->' in cue {index}")
        start = parse_timestamp(start_text)
        end = parse_timestamp(end_text)
        cues.append((index, start, end, rows[2:]))
    return cues


def format_srt(cues):
    """Serialise cue tuples back into SRT text."""
    out = []
    for cue in cues:
        out.append(str(cue[0]))
        out.append(f"{format_timestamp(cue[1])} --> {format_timestamp(cue[2])}")
        out.extend(cue[3])
        out.append("")
    return "\n".join(out)


def read_srt(path):
    with open(path, encoding="utf-8-sig") as handle:
        return parse_srt(handle.read())
"""Summary statistics for a list of cues."""

from .timecode import format_timestamp


def summarize(cues):
    if not cues:
        return {"cues": 0, "duration": "00:00:00,000", "longest_line": 0}
    total = sum(cue[2] - cue[1] for cue in cues)
    longest = max((len(line) for cue in cues for line in cue[3]), default=0)
    return {
        "cues": len(cues),
        "duration": format_timestamp(total),
        "longest_line": longest,
    }
"""Conversion between SRT/WebVTT timestamps and integer milliseconds."""

import re

_TIMESTAMP = re.compile(r"^(\d{2,}):(\d{2}):(\d{2}),(\d{3})$")


def parse_timestamp(text):
    """Return the number of milliseconds represented by an SRT timestamp."""
    match = _TIMESTAMP.match(text.strip())
    if not match:
        raise ValueError(f"not an SRT timestamp: {text!r}")
    hours, minutes, seconds, millis = (int(part) for part in match.groups())
    if minutes > 59 or seconds > 59:
        raise ValueError(f"minutes and seconds must be below 60: {text!r}")
    return ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis


def format_timestamp(ms):
    """Format milliseconds as an SRT timestamp. Negative values clamp to zero."""
    ms = max(0, int(ms))
    hours = ms // 3_600_000
    minutes = (ms // 60_000) % 60
    seconds = (ms // 1000) % 60
    millis = ms % 1000
    return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}"


# WebVTT timestamps use a full stop before the milliseconds and may omit the
# hours field entirely (MM:SS.mmm). A comma is accepted as well because some
# tools emit SRT-style separators in .vtt files.
_VTT_TIMESTAMP = re.compile(r"^(?:(\d{2,}):)?(\d{2}):(\d{2})[.,](\d{3})$")


def parse_vtt_timestamp(text):
    """Return the number of milliseconds represented by a WebVTT timestamp."""
    match = _VTT_TIMESTAMP.match(text.strip())
    if not match:
        raise ValueError(f"not a WebVTT timestamp: {text!r}")
    hours, minutes, seconds, millis = (int(part or 0) for part in match.groups())
    if minutes > 59 or seconds > 59:
        raise ValueError(f"minutes and seconds must be below 60: {text!r}")
    return ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis


def format_vtt_timestamp(ms):
    """Format milliseconds as a WebVTT timestamp (HH:MM:SS.mmm). Negative values clamp to zero."""
    return format_timestamp(ms).replace(",", ".")
"""Operations that change cue timing.

Cues may carry extra fields after (index, start_ms, end_ms, lines) -- WebVTT
cues add an identifier and cue settings -- and those are passed through as-is.
"""


def shift(cues, offset_ms):
    """Move every cue by offset_ms. Cues that would start before zero are clamped."""
    shifted = []
    for cue in cues:
        start = max(0, cue[1] + offset_ms)
        end = max(start, cue[2] + offset_ms)
        shifted.append((cue[0], start, end, list(cue[3])) + tuple(cue[4:]))
    return shifted


def renumber(cues):
    """Return the cues numbered from 1 in their current order."""
    return [(i, cue[1], cue[2], list(cue[3])) + tuple(cue[4:]) for i, cue in enumerate(cues, start=1)]
"""Parse and serialise WebVTT files, and convert cue text to and from SRT.

A WebVTT cue is represented like an SRT cue with two extra fields:
(index, start_ms, end_ms, lines, identifier, settings). identifier is the
optional cue identifier line (or None) and settings is the raw cue settings
string that follows the end timestamp (or "").

The parser follows the WebVTT spec where it matters for real files: an
optional BOM, CR/CRLF/LF line endings, header text and metadata after
"WEBVTT", NOTE/STYLE/REGION blocks, optional cue identifiers, timestamps
without hours, cue settings, and cues not separated by a blank line.
"""

import html
import re

from .timecode import format_vtt_timestamp, parse_vtt_timestamp


def _block_keyword(line, keyword):
    return line == keyword or line.startswith(keyword + " ") or line.startswith(keyword + "\t")


def _split_lines(text):
    if text.startswith("﻿"):
        text = text[1:]
    return text.replace("\r\n", "\n").replace("\r", "\n").split("\n")


def _parse_timing(line):
    start_text, _, rest = line.partition("-->")
    parts = rest.split()
    if not parts:
        raise ValueError(f"missing end timestamp: {line!r}")
    start = parse_vtt_timestamp(start_text)
    end = parse_vtt_timestamp(parts[0])
    return start, end, " ".join(parts[1:])


def parse_vtt_document(text):
    """Parse WebVTT text into (header_lines, preamble_blocks, cues).

    header_lines holds the "WEBVTT" line and any header text after it;
    preamble_blocks holds STYLE and REGION blocks (each a list of lines),
    which have to be kept before the first cue. NOTE comments are dropped.
    """
    lines = _split_lines(text)
    first = lines[0]
    if not _block_keyword(first, "WEBVTT"):
        raise ValueError("not a WebVTT file: missing 'WEBVTT' header")

    i = 1
    header = [first]
    while i < len(lines) and lines[i].strip() and "-->" not in lines[i]:
        header.append(lines[i])
        i += 1

    preamble = []
    cues = []
    while i < len(lines):
        if not lines[i].strip():
            i += 1
            continue
        block = []
        while i < len(lines) and lines[i].strip():
            block.append(lines[i])
            i += 1

        timing_rows = [n for n, row in enumerate(block) if "-->" in row]
        if not timing_rows:
            if not cues and (_block_keyword(block[0], "STYLE") or _block_keyword(block[0], "REGION")):
                preamble.append(block)
            # NOTE comments, and anything else without a timing line, are not cues.
            continue
        if _block_keyword(block[0], "NOTE"):
            continue

        # A block normally holds one cue, but a line containing "-->" always
        # starts a new cue, so cues missing their blank-line separator still parse.
        first_timing = timing_rows[0]
        identifier = block[first_timing - 1] if first_timing > 0 else None
        bounds = timing_rows + [len(block)]
        for n, row in enumerate(timing_rows):
            start, end, settings = _parse_timing(block[row])
            cue_lines = block[row + 1 : bounds[n + 1]]
            cues.append((len(cues) + 1, start, end, cue_lines, identifier if n == 0 else None, settings))
    return header, preamble, cues


def parse_vtt(text):
    """Parse WebVTT text into a list of cue tuples."""
    return parse_vtt_document(text)[2]


def format_vtt(cues, header=("WEBVTT",), preamble=()):
    """Serialise cue tuples as WebVTT text. Plain SRT-shaped cues are accepted too."""
    out = list(header)
    out.append("")
    for block in preamble:
        out.extend(block)
        out.append("")
    for cue in cues:
        identifier = cue[4] if len(cue) > 4 else None
        settings = cue[5] if len(cue) > 5 else ""
        if identifier:
            out.append(identifier)
        timing = f"{format_vtt_timestamp(cue[1])} --> {format_vtt_timestamp(cue[2])}"
        out.append(f"{timing} {settings}" if settings else timing)
        out.extend(cue[3])
        out.append("")
    return "\n".join(out)


def read_vtt_document(path):
    with open(path, encoding="utf-8-sig") as handle:
        return parse_vtt_document(handle.read())


def read_vtt(path):
    return read_vtt_document(path)[2]


# --- inline timestamps -----------------------------------------------------

_INLINE_TIMESTAMP = re.compile(r"<((?:\d{2,}:)?\d{2}:\d{2}\.\d{3})>")


def shift_inline_timestamps(cues, offset_ms):
    """Move karaoke-style inline timestamps (<00:00:01.500>) in cue text by offset_ms."""

    def move(match):
        return f"<{format_vtt_timestamp(parse_vtt_timestamp(match.group(1)) + offset_ms)}>"

    return [
        (cue[0], cue[1], cue[2], [_INLINE_TIMESTAMP.sub(move, line) for line in cue[3]]) + tuple(cue[4:])
        for cue in cues
    ]


# --- cue text conversion ---------------------------------------------------

_SIMPLE_STYLES = ("b", "i", "u")

# Ruby annotations (<rt>...</rt>; the end tag may be omitted before </ruby>)
# have no SRT equivalent and would otherwise run into the base text.
_VTT_RUBY_TEXT = re.compile(r"<rt\b[^>]*>.*?(?:</rt>|(?=</ruby>)|$)")
_VTT_TAG = re.compile(r"<(/?)([^>\s.]*)[^>]*>")


def vtt_text_to_srt(line):
    """Convert one line of WebVTT cue text to SRT text.

    <b>, <i> and <u> are kept (minus any classes); voice, class, language,
    ruby and timestamp tags are removed; character references are decoded.
    """

    def tag(match):
        closing, name = match.group(1), match.group(2).lower()
        return f"<{closing}{name}>" if name in _SIMPLE_STYLES else ""

    line = _VTT_RUBY_TEXT.sub("", line)
    line = _VTT_TAG.sub(tag, line)
    return html.unescape(line)


_SRT_STYLE_TAG = re.compile(r"<\s*(/?)\s*([biu])\s*>", re.IGNORECASE)
_SRT_FONT_TAG = re.compile(r"<\s*/?\s*font(?:\s[^>]*)?>", re.IGNORECASE)
_SRT_ASS_OVERRIDE = re.compile(r"\{\\[^}]*\}")


def srt_text_to_vtt(line):
    """Convert one line of SRT text to WebVTT cue text.

    <b>, <i> and <u> are kept; <font> tags and {\\an8}-style overrides, which
    WebVTT has no equivalent for, are dropped; everything else is escaped so
    that "&", "<" and "-->" in dialogue survive.
    """
    line = _SRT_FONT_TAG.sub("", line)
    line = _SRT_ASS_OVERRIDE.sub("", line)
    out = []
    pos = 0
    for match in _SRT_STYLE_TAG.finditer(line):
        out.append(_escape(line[pos : match.start()]))
        out.append(f"<{match.group(1)}{match.group(2).lower()}>")
        pos = match.end()
    out.append(_escape(line[pos:]))
    return "".join(out)


def _escape(text):
    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


def _convert_lines(lines, convert):
    # A line that converts to nothing (e.g. it held only tags) is dropped,
    # since an empty line would end the cue early in either format.
    converted = (convert(line) for line in lines)
    return [line for line in converted if line.strip()]


def vtt_to_srt_cues(cues):
    """Turn WebVTT cues into SRT cues numbered from 1."""
    return [(i, cue[1], cue[2], _convert_lines(cue[3], vtt_text_to_srt)) for i, cue in enumerate(cues, start=1)]


def srt_to_vtt_cues(cues):
    """Turn SRT cues into WebVTT cues (no identifiers or settings)."""
    return [(i, cue[1], cue[2], _convert_lines(cue[3], srt_text_to_vtt), None, "") for i, cue in enumerate(cues, start=1)]

exec
/bin/zsh -lc 'for p in /AGENTS.md /AGENTS.override.md <tmp-path> <tmp-path> <run:AGENTS>.md <run:AGENTS>.override.md; do if [ -f "$p" ]; then echo "$p"; cat "$p"; fi; done; cat tests/test_vtt.py; PYTHONDONTWRITEBYTECODE=1 python -m unittest discover -s tests; nl -ba srtkit/vtt.py | sed -n '"'60,90p;140,170p'" in <run:review-codex-on-claude>
 succeeded in 0ms:
import unittest

from srtkit.parser import format_srt, parse_srt
from srtkit.timecode import format_vtt_timestamp, parse_vtt_timestamp
from srtkit.vtt import (
    format_vtt,
    parse_vtt,
    parse_vtt_document,
    shift_inline_timestamps,
    srt_text_to_vtt,
    srt_to_vtt_cues,
    vtt_text_to_srt,
    vtt_to_srt_cues,
)

# Modelled on files produced by YouTube, browsers' caption editors and
# hand-written examples from the spec: BOM, CRLF, header metadata, STYLE and
# NOTE blocks, identifiers, cue settings, short timestamps, and two cues with
# no blank line between them.
REAL_WORLD = (
    "﻿WEBVTT - Interview\r\n"
    "Kind: captions\r\n"
    "Language: en\r\n"
    "\r\n"
    "STYLE\r\n"
    "::cue { color: yellow }\r\n"
    "\r\n"
    "NOTE This comment\r\n"
    "spans two lines\r\n"
    "\r\n"
    "intro\r\n"
    "00:01.000 --> 00:03.200 align:start position:10%\r\n"
    "<v Host>Thanks &amp; welcome</v>\r\n"
    "<c.yellow><i>today</i></c>\r\n"
    "00:03.400 --> 00:06.900\r\n"
    "<00:03.400><c>karaoke</c><00:04.000><c> words</c>\r\n"
    "\r\n"
    "\r\n"
    "NOTE trailing comment\r\n"
    "\r\n"
    "01:00:00.000-->01:00:01.000\r\n"
    "1 &lt; 2\r\n"
)


class VttTimestampTests(unittest.TestCase):
    def test_with_hours(self):
        self.assertEqual(parse_vtt_timestamp("01:02:05.500"), 3_725_500)

    def test_without_hours(self):
        self.assertEqual(parse_vtt_timestamp("02:05.500"), 125_500)

    def test_long_hours(self):
        self.assertEqual(parse_vtt_timestamp("100:00:00.000"), 360_000_000)

    def test_rejects_bad_seconds(self):
        with self.assertRaises(ValueError):
            parse_vtt_timestamp("00:60.000")

    def test_rejects_garbage(self):
        with self.assertRaises(ValueError):
            parse_vtt_timestamp("1.5")

    def test_format(self):
        self.assertEqual(format_vtt_timestamp(125_500), "00:02:05.500")


class VttParserTests(unittest.TestCase):
    def test_real_world_document(self):
        header, preamble, cues = parse_vtt_document(REAL_WORLD)
        self.assertEqual(header, ["WEBVTT - Interview", "Kind: captions", "Language: en"])
        self.assertEqual(preamble, [["STYLE", "::cue { color: yellow }"]])
        self.assertEqual(
            cues,
            [
                (1, 1000, 3200, ["<v Host>Thanks &amp; welcome</v>", "<c.yellow><i>today</i></c>"], "intro", "align:start position:10%"),
                (2, 3400, 6900, ["<00:03.400><c>karaoke</c><00:04.000><c> words</c>"], None, ""),
                (3, 3_600_000, 3_601_000, ["1 &lt; 2"], None, ""),
            ],
        )

    def test_minimal(self):
        self.assertEqual(parse_vtt("WEBVTT\n\n00:01.000 --> 00:02.000\nHi\n"), [(1, 1000, 2000, ["Hi"], None, "")])

    def test_header_only(self):
        self.assertEqual(parse_vtt("WEBVTT\n"), [])

    def test_cr_only_line_endings(self):
        self.assertEqual(parse_vtt("WEBVTT\r\r00:01.000 --> 00:02.000\rHi\r"), [(1, 1000, 2000, ["Hi"], None, "")])

    def test_cue_directly_after_header(self):
        self.assertEqual(parse_vtt("WEBVTT\n00:01.000 --> 00:02.000\nHi"), [(1, 1000, 2000, ["Hi"], None, "")])

    def test_numeric_identifiers(self):
        cues = parse_vtt("WEBVTT\n\n1\n00:01.000 --> 00:02.000\nHi\n\n2\n00:03.000 --> 00:04.000\nBye\n")
        self.assertEqual([cue[4] for cue in cues], ["1", "2"])
        self.assertEqual([cue[3] for cue in cues], [["Hi"], ["Bye"]])

    def test_style_after_cues_is_ignored(self):
        header, preamble, cues = parse_vtt_document("WEBVTT\n\n00:01.000 --> 00:02.000\nHi\n\nSTYLE\n::cue {}\n")
        self.assertEqual(preamble, [])
        self.assertEqual(len(cues), 1)

    def test_missing_header(self):
        with self.assertRaises(ValueError):
            parse_vtt("1\n00:00:01,000 --> 00:00:02,000\nHi\n")

    def test_header_must_be_exact_word(self):
        with self.assertRaises(ValueError):
            parse_vtt("WEBVTTX\n")

    def test_bad_timestamp(self):
        with self.assertRaises(ValueError):
            parse_vtt("WEBVTT\n\n00:01 --> 00:02.000\nHi\n")

    def test_format_round_trip(self):
        text = (
            "WEBVTT - Interview\n\nSTYLE\n::cue {}\n\n"
            "intro\n00:00:01.000 --> 00:00:02.000 line:0\nHi\n\n"
            "00:00:03.000 --> 00:00:04.000\nBye\n"
        )
        header, preamble, cues = parse_vtt_document(text)
        self.assertEqual(format_vtt(cues, header, preamble), text)


class VttTextConversionTests(unittest.TestCase):
    def test_vtt_to_srt_keeps_basic_styles(self):
        self.assertEqual(vtt_text_to_srt("<b.loud>Hey</b> <i>you</i> <u>there</u>"), "<b>Hey</b> <i>you</i> <u>there</u>")

    def test_vtt_to_srt_strips_other_tags(self):
        self.assertEqual(vtt_text_to_srt("<v.first Bob Smith>Hi <lang fr>ami</lang></v>"), "Hi ami")
        self.assertEqual(vtt_text_to_srt("<00:00:01.000><c>one</c><00:00:02.000><c> two</c>"), "one two")

    def test_vtt_to_srt_drops_ruby_text(self):
        self.assertEqual(vtt_text_to_srt("<ruby>漢<rt>kan</rt>字<rt>ji</ruby>"), "漢字")

    def test_vtt_to_srt_decodes_entities(self):
        self.assertEqual(vtt_text_to_srt("Tom &amp; Jerry &lt;3 &gt; &lrm;x&nbsp;y"), "Tom & Jerry <3 > ‎x y")

    def test_srt_to_vtt_keeps_basic_styles(self):
        self.assertEqual(srt_text_to_vtt("<I>Hi</I> < b >there</b>"), "<i>Hi</i> <b>there</b>")

    def test_srt_to_vtt_escapes_markup(self):
        self.assertEqual(srt_text_to_vtt("Tom & Jerry <3 --> me"), "Tom &amp; Jerry &lt;3 --&gt; me")

    def test_srt_to_vtt_drops_font_and_overrides(self):
        self.assertEqual(srt_text_to_vtt('{\\an8}<font color="#ff0000">Red</font>'), "Red")

    def test_lines_that_become_empty_are_dropped(self):
        cues = vtt_to_srt_cues([(1, 0, 1000, ["<c></c>", "Text"], None, "")])
        self.assertEqual(cues, [(1, 0, 1000, ["Text"])])

    def test_vtt_to_srt_document(self):
        srt = format_srt(vtt_to_srt_cues(parse_vtt(REAL_WORLD)))
        self.assertEqual(
            srt,
            "1\n00:00:01,000 --> 00:00:03,200\nThanks & welcome\n<i>today</i>\n\n"
            "2\n00:00:03,400 --> 00:00:06,900\nkaraoke words\n\n"
            "3\n01:00:00,000 --> 01:00:01,000\n1 < 2\n",
        )
        self.assertEqual(len(parse_srt(srt)), 3)

    def test_srt_to_vtt_round_trip(self):
        srt = "1\n00:00:01,000 --> 00:00:02,000\n<i>Tom & Jerry</i>\n\n2\n00:00:03,000 --> 00:00:04,500\nBye\n"
        vtt = format_vtt(srt_to_vtt_cues(parse_srt(srt)))
        self.assertEqual(
            vtt,
            "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n<i>Tom &amp; Jerry</i>\n\n00:00:03.000 --> 00:00:04.500\nBye\n",
        )
        self.assertEqual(format_srt(vtt_to_srt_cues(parse_vtt(vtt))), srt)


class InlineTimestampTests(unittest.TestCase):
    def test_shift(self):
        cues = [(1, 1000, 3000, ["<00:01.500>a <00:00:02.000>b"], None, "")]
        self.assertEqual(shift_inline_timestamps(cues, -1000)[0][3], ["<00:00:00.500>a <00:00:01.000>b"])


if __name__ == "__main__":
    unittest.main()
..EEEEEEEEE........................................
======================================================================
ERROR: test_convert_rejects_same_format (test_cli.VttCliTests.test_convert_rejects_same_format)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<run:review-codex-on-claude>/tests/test_cli.py", line 42, in setUp
    self.tmp = tempfile.TemporaryDirectory()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 886, in __init__
    self.name = mkdtemp(suffix, prefix, dir)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 373, in mkdtemp
    prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir)
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 126, in _sanitize_params
    dir = gettempdir()
          ^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 315, in gettempdir
    return _os.fsdecode(_gettempdir())
                        ^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 308, in _gettempdir
    tempdir = _get_default_tempdir()
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 223, in _get_default_tempdir
    raise FileNotFoundError(_errno.ENOENT,
FileNotFoundError: [Errno 2] No usable temporary directory found in ['<tmp>', '/tmp', '/var/tmp', '/usr/tmp', '<run:review-codex-on-claude>']

======================================================================
ERROR: test_convert_rejects_unknown_extension (test_cli.VttCliTests.test_convert_rejects_unknown_extension)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<run:review-codex-on-claude>/tests/test_cli.py", line 42, in setUp
    self.tmp = tempfile.TemporaryDirectory()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 886, in __init__
    self.name = mkdtemp(suffix, prefix, dir)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 373, in mkdtemp
    prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir)
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 126, in _sanitize_params
    dir = gettempdir()
          ^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 315, in gettempdir
    return _os.fsdecode(_gettempdir())
                        ^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 308, in _gettempdir
    tempdir = _get_default_tempdir()
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 223, in _get_default_tempdir
    raise FileNotFoundError(_errno.ENOENT,
FileNotFoundError: [Errno 2] No usable temporary directory found in ['<tmp>', '/tmp', '/var/tmp', '/usr/tmp', '<run:review-codex-on-claude>']

======================================================================
ERROR: test_convert_reports_bad_input (test_cli.VttCliTests.test_convert_reports_bad_input)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<run:review-codex-on-claude>/tests/test_cli.py", line 42, in setUp
    self.tmp = tempfile.TemporaryDirectory()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 886, in __init__
    self.name = mkdtemp(suffix, prefix, dir)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 373, in mkdtemp
    prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir)
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 126, in _sanitize_params
    dir = gettempdir()
          ^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 315, in gettempdir
    return _os.fsdecode(_gettempdir())
                        ^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 308, in _gettempdir
    tempdir = _get_default_tempdir()
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 223, in _get_default_tempdir
    raise FileNotFoundError(_errno.ENOENT,
FileNotFoundError: [Errno 2] No usable temporary directory found in ['<tmp>', '/tmp', '/var/tmp', '/usr/tmp', '<run:review-codex-on-claude>']

======================================================================
ERROR: test_convert_round_trip (test_cli.VttCliTests.test_convert_round_trip)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<run:review-codex-on-claude>/tests/test_cli.py", line 42, in setUp
    self.tmp = tempfile.TemporaryDirectory()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 886, in __init__
    self.name = mkdtemp(suffix, prefix, dir)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 373, in mkdtemp
    prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir)
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 126, in _sanitize_params
    dir = gettempdir()
          ^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 315, in gettempdir
    return _os.fsdecode(_gettempdir())
                        ^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 308, in _gettempdir
    tempdir = _get_default_tempdir()
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 223, in _get_default_tempdir
    raise FileNotFoundError(_errno.ENOENT,
FileNotFoundError: [Errno 2] No usable temporary directory found in ['<tmp>', '/tmp', '/var/tmp', '/usr/tmp', '<run:review-codex-on-claude>']

======================================================================
ERROR: test_convert_srt_to_vtt (test_cli.VttCliTests.test_convert_srt_to_vtt)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<run:review-codex-on-claude>/tests/test_cli.py", line 42, in setUp
    self.tmp = tempfile.TemporaryDirectory()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 886, in __init__
    self.name = mkdtemp(suffix, prefix, dir)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 373, in mkdtemp
    prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir)
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 126, in _sanitize_params
    dir = gettempdir()
          ^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 315, in gettempdir
    return _os.fsdecode(_gettempdir())
                        ^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 308, in _gettempdir
    tempdir = _get_default_tempdir()
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 223, in _get_default_tempdir
    raise FileNotFoundError(_errno.ENOENT,
FileNotFoundError: [Errno 2] No usable temporary directory found in ['<tmp>', '/tmp', '/var/tmp', '/usr/tmp', '<run:review-codex-on-claude>']

======================================================================
ERROR: test_convert_uppercase_extensions (test_cli.VttCliTests.test_convert_uppercase_extensions)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<run:review-codex-on-claude>/tests/test_cli.py", line 42, in setUp
    self.tmp = tempfile.TemporaryDirectory()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 886, in __init__
    self.name = mkdtemp(suffix, prefix, dir)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 373, in mkdtemp
    prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir)
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 126, in _sanitize_params
    dir = gettempdir()
          ^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 315, in gettempdir
    return _os.fsdecode(_gettempdir())
                        ^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 308, in _gettempdir
    tempdir = _get_default_tempdir()
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 223, in _get_default_tempdir
    raise FileNotFoundError(_errno.ENOENT,
FileNotFoundError: [Errno 2] No usable temporary directory found in ['<tmp>', '/tmp', '/var/tmp', '/usr/tmp', '<run:review-codex-on-claude>']

======================================================================
ERROR: test_convert_vtt_to_srt (test_cli.VttCliTests.test_convert_vtt_to_srt)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<run:review-codex-on-claude>/tests/test_cli.py", line 42, in setUp
    self.tmp = tempfile.TemporaryDirectory()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 886, in __init__
    self.name = mkdtemp(suffix, prefix, dir)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 373, in mkdtemp
    prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir)
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 126, in _sanitize_params
    dir = gettempdir()
          ^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 315, in gettempdir
    return _os.fsdecode(_gettempdir())
                        ^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 308, in _gettempdir
    tempdir = _get_default_tempdir()
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 223, in _get_default_tempdir
    raise FileNotFoundError(_errno.ENOENT,
FileNotFoundError: [Errno 2] No usable temporary directory found in ['<tmp>', '/tmp', '/var/tmp', '/usr/tmp', '<run:review-codex-on-claude>']

======================================================================
ERROR: test_shift_vtt_stays_vtt (test_cli.VttCliTests.test_shift_vtt_stays_vtt)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<run:review-codex-on-claude>/tests/test_cli.py", line 42, in setUp
    self.tmp = tempfile.TemporaryDirectory()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 886, in __init__
    self.name = mkdtemp(suffix, prefix, dir)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 373, in mkdtemp
    prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir)
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 126, in _sanitize_params
    dir = gettempdir()
          ^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 315, in gettempdir
    return _os.fsdecode(_gettempdir())
                        ^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 308, in _gettempdir
    tempdir = _get_default_tempdir()
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 223, in _get_default_tempdir
    raise FileNotFoundError(_errno.ENOENT,
FileNotFoundError: [Errno 2] No usable temporary directory found in ['<tmp>', '/tmp', '/var/tmp', '/usr/tmp', '<run:review-codex-on-claude>']

======================================================================
ERROR: test_stats_vtt (test_cli.VttCliTests.test_stats_vtt)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "<run:review-codex-on-claude>/tests/test_cli.py", line 42, in setUp
    self.tmp = tempfile.TemporaryDirectory()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 886, in __init__
    self.name = mkdtemp(suffix, prefix, dir)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 373, in mkdtemp
    prefix, suffix, dir, output_type = _sanitize_params(prefix, suffix, dir)
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 126, in _sanitize_params
    dir = gettempdir()
          ^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 315, in gettempdir
    return _os.fsdecode(_gettempdir())
                        ^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 308, in _gettempdir
    tempdir = _get_default_tempdir()
              ^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.12/3.12.15/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tempfile.py", line 223, in _get_default_tempdir
    raise FileNotFoundError(_errno.ENOENT,
FileNotFoundError: [Errno 2] No usable temporary directory found in ['<tmp>', '/tmp', '/var/tmp', '/usr/tmp', '<run:review-codex-on-claude>']

----------------------------------------------------------------------
Ran 51 tests in 0.004s

FAILED (errors=9)
    60	    while i < len(lines):
    61	        if not lines[i].strip():
    62	            i += 1
    63	            continue
    64	        block = []
    65	        while i < len(lines) and lines[i].strip():
    66	            block.append(lines[i])
    67	            i += 1
    68	
    69	        timing_rows = [n for n, row in enumerate(block) if "-->" in row]
    70	        if not timing_rows:
    71	            if not cues and (_block_keyword(block[0], "STYLE") or _block_keyword(block[0], "REGION")):
    72	                preamble.append(block)
    73	            # NOTE comments, and anything else without a timing line, are not cues.
    74	            continue
    75	        if _block_keyword(block[0], "NOTE"):
    76	            continue
    77	
    78	        # A block normally holds one cue, but a line containing "-->" always
    79	        # starts a new cue, so cues missing their blank-line separator still parse.
    80	        first_timing = timing_rows[0]
    81	        identifier = block[first_timing - 1] if first_timing > 0 else None
    82	        bounds = timing_rows + [len(block)]
    83	        for n, row in enumerate(timing_rows):
    84	            start, end, settings = _parse_timing(block[row])
    85	            cue_lines = block[row + 1 : bounds[n + 1]]
    86	            cues.append((len(cues) + 1, start, end, cue_lines, identifier if n == 0 else None, settings))
    87	    return header, preamble, cues
    88	
    89	
    90	def parse_vtt(text):
   140	# --- cue text conversion ---------------------------------------------------
   141	
   142	_SIMPLE_STYLES = ("b", "i", "u")
   143	
   144	# Ruby annotations (<rt>...</rt>; the end tag may be omitted before </ruby>)
   145	# have no SRT equivalent and would otherwise run into the base text.
   146	_VTT_RUBY_TEXT = re.compile(r"<rt\b[^>]*>.*?(?:</rt>|(?=</ruby>)|$)")
   147	_VTT_TAG = re.compile(r"<(/?)([^>\s.]*)[^>]*>")
   148	
   149	
   150	def vtt_text_to_srt(line):
   151	    """Convert one line of WebVTT cue text to SRT text.
   152	
   153	    <b>, <i> and <u> are kept (minus any classes); voice, class, language,
   154	    ruby and timestamp tags are removed; character references are decoded.
   155	    """
   156	
   157	    def tag(match):
   158	        closing, name = match.group(1), match.group(2).lower()
   159	        return f"<{closing}{name}>" if name in _SIMPLE_STYLES else ""
   160	
   161	    line = _VTT_RUBY_TEXT.sub("", line)
   162	    line = _VTT_TAG.sub(tag, line)
   163	    return html.unescape(line)
   164	
   165	
   166	_SRT_STYLE_TAG = re.compile(r"<\s*(/?)\s*([biu])\s*>", re.IGNORECASE)
   167	_SRT_FONT_TAG = re.compile(r"<\s*/?\s*font(?:\s[^>]*)?>", re.IGNORECASE)
   168	_SRT_ASS_OVERRIDE = re.compile(r"\{\\[^}]*\}")
   169	
   170	

codex
Multiline ruby annotations are converted incorrectly because markup processing does not retain state across cue lines. Full test execution was also limited by the read-only environment, which prevented temporary-directory creation.

Review comment:

- [P2] Remove ruby annotations across cue line boundaries — <run:review-codex-on-claude>/srtkit/vtt.py:161-162
  When a ruby annotation spans multiple cue lines, conversion runs independently on each line and loses the `<rt>` state. For example, cue lines `<ruby>漢<rt>` and `kan</rt></ruby>` produce `漢` followed by `kan`, although the annotation should be removed. Process ruby markup across the complete cue before splitting it back into lines so annotation text does not leak into converted subtitles or inflate WebVTT statistics.
Multiline ruby annotations are converted incorrectly because markup processing does not retain state across cue lines. Full test execution was also limited by the read-only environment, which prevented temporary-directory creation.

Review comment:

- [P2] Remove ruby annotations across cue line boundaries — <run:review-codex-on-claude>/srtkit/vtt.py:161-162
  When a ruby annotation spans multiple cue lines, conversion runs independently on each line and loses the `<rt>` state. For example, cue lines `<ruby>漢<rt>` and `kan</rt></ruby>` produce `漢` followed by `kan`, although the annotation should be removed. Process ruby markup across the complete cue before splitting it back into lines so annotation text does not leak into converted subtitles or inflate WebVTT statistics.
codex review seconds: 22
