From ba9ce9880587dbb442ea8fb8253d289e197f007d Mon Sep 17 00:00:00 2001 From: Zeke Sikelianos Date: Tue, 28 Jul 2026 18:58:00 -0700 Subject: [PATCH 1/4] fix: merge curly-apostrophe contractions; add Claude/ChatGPT/Codex/bloat compound merges whisper.cpp emits curly apostrophes (U+2019) in contractions like isn't, didn't, it's, but CONTRACTION only matched the straight ASCII apostrophe, so these words never merged and rendered with a stray space ("It 's"). Also add compound-word merges for tokenizer splits observed in the wild: Claude -> Cla+ude, ChatGPT -> Chat+G+P+T, Codex -> Code+x, bloat -> blo+at. --- caption.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/caption.py b/caption.py index 9ca6615..a25b5e0 100644 --- a/caption.py +++ b/caption.py @@ -59,6 +59,10 @@ ["b", "rowse"], # browse ["b", "rows", "ing"], # browsing ["b", "rows", "ed"], # browsed + ["cla", "ude"], # Claude + ["chat", "g", "p", "t"], # ChatGPT + ["code", "x"], # Codex + ["blo", "at"], # bloat ] # --------------------------------------------------------------------------- @@ -296,7 +300,7 @@ def parse_wts(wts_path): # Merge trailing punctuation and contraction suffixes into preceding word TRAILING_PUNCT = re.compile(r'^[,\.!\?;:\-]+$') - CONTRACTION = re.compile(r"^'[a-zA-Z]{1,2}$") # 't, 's, 're, 've, 'll, 'd, 'm + CONTRACTION = re.compile(r"^['\u2018\u2019][a-zA-Z]{1,2}$") # 't, 's, 're, 've, 'll, 'd, 'm — straight or curly apostrophe words = [] for tok in raw_tokens: From 1db7216141b213ead79e904793ff1f9eb627bc41 Mon Sep 17 00:00:00 2001 From: Zeke Sikelianos Date: Tue, 28 Jul 2026 19:30:34 -0700 Subject: [PATCH 2/4] fix: add unadulterated compound merge; shrink default caption font size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unadulterated was split by whisper into un+ad+ul+ter+ated, now merges back - FONT_SIZE 72 -> 56 (and proportional PAD_X/PAD_Y/CORNER_R) — captions were oversized on portrait/smaller-resolution videos --- README.md | 8 ++++---- caption.py | 9 +++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 588001e..86482a7 100644 --- a/README.md +++ b/README.md @@ -167,10 +167,10 @@ All visual parameters are constants at the top of `caption.py`: | Constant | Default | Description | | -------- | ------- | ----------- | | `FONT_NAME` | `Arial` | Caption font family | -| `FONT_SIZE` | `72` | Font size in script pixels | -| `PAD_X` | `24` | Horizontal padding inside background box | -| `PAD_Y` | `28` | Vertical padding inside background box | -| `CORNER_R` | `18` | Background box corner radius | +| `FONT_SIZE` | `56` | Font size in script pixels | +| `PAD_X` | `19` | Horizontal padding inside background box | +| `PAD_Y` | `22` | Vertical padding inside background box | +| `CORNER_R` | `14` | Background box corner radius | | `MARGIN_BOTTOM` | `80` | Distance from bottom of frame to text | | `CAPTION_POSITION` | `bottom` | Default caption position: `top`, `center`, or `bottom` | | `ALPHA_DIM` | `&H99&` | Inactive word opacity (~60% opaque) | diff --git a/caption.py b/caption.py index a25b5e0..2516ca6 100644 --- a/caption.py +++ b/caption.py @@ -26,11 +26,11 @@ VIDEO_HEIGHT = 1080 FONT_NAME = "Arial" -FONT_SIZE = 72 # points; scale with video resolution +FONT_SIZE = 56 # points; scale with video resolution -PAD_X = 24 # horizontal padding inside box (pixels) -PAD_Y = 28 # vertical padding inside box (pixels) -CORNER_R = 18 # box corner radius (pixels) +PAD_X = 19 # horizontal padding inside box (pixels) +PAD_Y = 22 # vertical padding inside box (pixels) +CORNER_R = 14 # box corner radius (pixels) MARGIN_BOTTOM = 80 # distance from bottom of frame to text baseline @@ -63,6 +63,7 @@ ["chat", "g", "p", "t"], # ChatGPT ["code", "x"], # Codex ["blo", "at"], # bloat + ["un", "ad", "ul", "ter", "ated"], # unadulterated ] # --------------------------------------------------------------------------- From a0baa041e0b89242d32dceaadf846b9b9d244c6e Mon Sep 17 00:00:00 2001 From: Zeke Sikelianos Date: Tue, 28 Jul 2026 20:56:13 -0700 Subject: [PATCH 3/4] fix: handle alternate Claude tokenizer split (Claud+e) whisper.cpp sometimes splits Claude as Cla+ude and other times as Claud+e depending on surrounding audio/context. Add the second split pattern too. --- caption.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/caption.py b/caption.py index 2516ca6..65b7cd0 100644 --- a/caption.py +++ b/caption.py @@ -59,7 +59,8 @@ ["b", "rowse"], # browse ["b", "rows", "ing"], # browsing ["b", "rows", "ed"], # browsed - ["cla", "ude"], # Claude + ["cla", "ude"], # Claude (tokenizer split A) + ["claud", "e"], # Claude (tokenizer split B) ["chat", "g", "p", "t"], # ChatGPT ["code", "x"], # Codex ["blo", "at"], # bloat From 161ebf4738b97276ee3db976fbf634d60ed08bac Mon Sep 17 00:00:00 2001 From: Zeke Sikelianos Date: Tue, 28 Jul 2026 22:24:09 -0700 Subject: [PATCH 4/4] fix: wrap long caption chunks across multiple lines Chunks whose text exceeded the caption box width were overflowing past the frame edges instead of wrapping. Shrinks default font size, wraps words greedily to fit MAX_CAPTION_WIDTH_RATIO of the frame, and grows the box height/position per-anchor to fit multi-line chunks. --- caption.py | 132 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 90 insertions(+), 42 deletions(-) diff --git a/caption.py b/caption.py index 65b7cd0..5981a2d 100644 --- a/caption.py +++ b/caption.py @@ -26,14 +26,17 @@ VIDEO_HEIGHT = 1080 FONT_NAME = "Arial" -FONT_SIZE = 56 # points; scale with video resolution +FONT_SIZE = 44 # points; scale with video resolution -PAD_X = 19 # horizontal padding inside box (pixels) -PAD_Y = 22 # vertical padding inside box (pixels) -CORNER_R = 14 # box corner radius (pixels) +PAD_X = 15 # horizontal padding inside box (pixels) +PAD_Y = 17 # vertical padding inside box (pixels) +CORNER_R = 11 # box corner radius (pixels) MARGIN_BOTTOM = 80 # distance from bottom of frame to text baseline +MAX_CAPTION_WIDTH_RATIO = 0.90 # max caption box width, as a fraction of video width +LINE_SPACING = 1.25 # vertical spacing between wrapped lines, as a multiple of FONT_SIZE + # ASS alpha: 0x00 = fully opaque, 0xFF = fully transparent ALPHA_DIM = "&H99&" # inactive words (~60% opaque) ALPHA_BRIGHT = "&H00&" # active word (fully opaque) @@ -389,24 +392,63 @@ def rounded_rect(x, y, w, h, r): def get_caption_layout(position, video_width, video_height): cx = video_width // 2 - text_h = FONT_SIZE - box_h = text_h + PAD_Y * 2 if position == "top": text_y = MARGIN_BOTTOM + PAD_Y - box_top = MARGIN_BOTTOM - return "8", cx, text_y, box_top + return "8", cx, text_y if position == "center": text_y = video_height // 2 - box_top = (video_height - box_h) / 2 - return "5", cx, text_y, box_top + return "5", cx, text_y if position == "bottom": text_y = video_height - MARGIN_BOTTOM - box_top = text_y - text_h - PAD_Y - return "2", cx, text_y, box_top + return "2", cx, text_y + raise ValueError(f"Invalid caption position: {position}") + + +def compute_box_top(position, text_y, box_h, video_height): + """Box top position, accounting for a variable box height (multi-line + chunks). Each anchor grows the box in the direction away from its edge: + top-anchored boxes grow downward, bottom-anchored boxes grow upward, + center-anchored boxes grow symmetrically.""" + if position == "top": + return text_y - PAD_Y + if position == "center": + return (video_height - box_h) / 2 + if position == "bottom": + return text_y - box_h + PAD_Y raise ValueError(f"Invalid caption position: {position}") +def estimate_word_width(word, font_size=FONT_SIZE): + return len(word) * font_size * 0.52 + + +def wrap_chunk_words(chunk_words, max_text_width, font_size=FONT_SIZE): + """Greedily pack words into lines that fit within max_text_width.""" + space_w = font_size * 0.28 + lines = [] + current = [] + current_w = 0.0 + for w in chunk_words: + word_w = estimate_word_width(w["word"], font_size) + added_w = word_w if not current else word_w + space_w + if current and current_w + added_w > max_text_width: + lines.append(current) + current = [w] + current_w = word_w + else: + current.append(w) + current_w += added_w + if current: + lines.append(current) + return lines + + +def line_width(line, font_size=FONT_SIZE): + space_w = font_size * 0.28 + return sum(estimate_word_width(w["word"], font_size) for w in line) + space_w * (len(line) - 1) + + def build_ass(chunks, video_width=VIDEO_WIDTH, video_height=VIDEO_HEIGHT, colors=None, position=CAPTION_POSITION): colors = colors or {} header = ASS_HEADER.format( @@ -418,11 +460,11 @@ def build_ass(chunks, video_width=VIDEO_WIDTH, video_height=VIDEO_HEIGHT, colors box_col = colors.get("box", COL_BOX), ) - text_h = FONT_SIZE - box_h = text_h + PAD_Y * 2 - align, cx, text_y, box_top = get_caption_layout(position, video_width, video_height) + align, cx, text_y = get_caption_layout(position, video_width, video_height) pos_tag = rf"{{\an{align}\pos({cx},{text_y})}}" + max_text_width = video_width * MAX_CAPTION_WIDTH_RATIO - PAD_X * 2 + lines = [] for chunk in chunks: @@ -432,15 +474,17 @@ def build_ass(chunks, video_width=VIDEO_WIDTH, video_height=VIDEO_HEIGHT, colors chunk_start_ts = sec_to_ass(chunk_start) chunk_end_ts = sec_to_ass(chunk_end) - # Estimate box width from character counts - space_w = FONT_SIZE * 0.28 - total_text_w = ( - sum(len(w["word"]) * FONT_SIZE * 0.52 for w in chunk_words) - + space_w * (len(chunk_words) - 1) - ) - box_w = total_text_w + PAD_X * 2 + # Wrap words onto multiple lines if the chunk is too wide for the frame + word_lines = wrap_chunk_words(chunk_words, max_text_width) + + box_w = max(line_width(wl) for wl in word_lines) + PAD_X * 2 box_left = cx - box_w / 2 + line_height = FONT_SIZE * LINE_SPACING + text_h = line_height * len(word_lines) + box_h = text_h + PAD_Y * 2 + box_top = compute_box_top(position, text_y, box_h, video_height) + # Per-chunk color overrides (--colorize per-chunk). Empty for global/off, # where colors come from the style header instead. chunk_colors = chunk.get("colors") @@ -459,28 +503,32 @@ def build_ass(chunks, video_width=VIDEO_WIDTH, video_height=VIDEO_HEIGHT, colors box_text = r"{" + box_override + r"\p1\an7\pos(0,0)}" + drawing + r"{\p0}" lines.append(f"Dialogue: 0,{chunk_start_ts},{chunk_end_ts},Box,,0,0,0,,{box_text}") - # Layer 1: caption text — one line per chunk, \1a animated per word - parts = [] - for i, w in enumerate(chunk_words): - t_in = max(0, int((w["start"] - chunk_start) * 1000)) - t_out = int((w["end"] - chunk_start) * 1000) - - if i == 0: - # First word starts bright (t=0 snap is unreliable in libass) - word_tags = ( - rf"{{\1a{ALPHA_BRIGHT}" - rf"\t({t_out},{t_out},\1a{ALPHA_DIM})}}" - ) - else: - word_tags = ( - rf"{{\1a{ALPHA_DIM}" - rf"\t({t_in},{t_in},\1a{ALPHA_BRIGHT})" - rf"\t({t_out},{t_out},\1a{ALPHA_DIM})}}" - ) - parts.append(word_tags + w["word"]) + # Layer 1: caption text — one or more lines per chunk, \1a animated per word + rendered_lines = [] + for word_line in word_lines: + parts = [] + for w in word_line: + is_first_word_overall = w is chunk_words[0] + t_in = max(0, int((w["start"] - chunk_start) * 1000)) + t_out = int((w["end"] - chunk_start) * 1000) + + if is_first_word_overall: + # First word starts bright (t=0 snap is unreliable in libass) + word_tags = ( + rf"{{\1a{ALPHA_BRIGHT}" + rf"\t({t_out},{t_out},\1a{ALPHA_DIM})}}" + ) + else: + word_tags = ( + rf"{{\1a{ALPHA_DIM}" + rf"\t({t_in},{t_in},\1a{ALPHA_BRIGHT})" + rf"\t({t_out},{t_out},\1a{ALPHA_DIM})}}" + ) + parts.append(word_tags + w["word"]) + rendered_lines.append(" ".join(parts)) caption_prefix = (rf"{{{text_override}}}" if text_override else "") + pos_tag - caption_text = caption_prefix + " ".join(parts) + caption_text = caption_prefix + r"\N".join(rendered_lines) lines.append(f"Dialogue: 1,{chunk_start_ts},{chunk_end_ts},Caption,,0,0,0,,{caption_text}") return header + "\n".join(lines) + "\n"