# ─── Post-Processing Engine v10 ──────────────────────────────────────────── MIN_DISPLAY = 1.5 MAX_DISPLAY = 7.0 # استاندارد Netflix/Amazon/Disney+: حداکثر ۷ ثانیه برای هر cue MERGE_MAX_CHARS = 72 MAX_GAP = 1.5 WRAP_CHARS = 42 # استاندارد Netflix/Amazon/Disney+: حداکثر ۴۲ کاراکتر در هر خط (نه در کل cue) MAX_LINES = 2 # استاندارد: حداکثر ۲ خط در هر cue CPS_LIMIT = 17 # حداکثر کاراکتر قابل‌خواندن در ثانیه (استاندارد بین ۱۷ تا ۲۰ کاراکتر بر ثانیه‌ست، ۱۷ رو محافظه‌کارانه انتخاب کردیم) # کلمه‌های کوچیک و به‌هم‌چسبیده‌ای که نباید تنها و جدا از کلمه‌ی بعدشون # ته یه segment (چه raw، چه بعد از split) بمونن. # هم توی split_long_segment استفاده میشه، هم توی rebalance_boundaries. WEAK_ENDINGS = { 'in', 'my', 'your', 'his', 'her', 'our', 'their', 'its', 'the', 'a', 'an', 'of', 'to', 'at', 'on', 'by', 'for', 'with', 'is', 'are', 'was', 'were', 'and', 'or', 'but', 'so', 'if', 'that', 'this', 'we', 'you', 'he', 'she', 'they', } def format_timestamp(seconds: float) -> str: ms = round((seconds % 1) * 1000) s = int(seconds) % 60 m = int(seconds // 60) % 60 h = int(seconds // 3600) return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" SENTENCE_ENDERS = {'.', '!', '?'} LEAD_WORD_BUDGET = 9 # حداکثر تعداد کلمه‌ای که اجازه داریم از ابتدای segment بعدی به عقب بکشیم LEAD_CHAR_BUDGET = 55 # حداکثر تعداد کاراکتر همون دنباله def rebalance_boundaries(segments: list) -> list: """ مشکلی که این تابع حل می‌کنه: خودِ Whisper بعضی‌وقتا وسط یه عبارت به‌هم‌چسبیده (مثل «in my | opinion» یا «of this | video») مرز segment می‌ذاره — نه سر یه مکث طبیعی. چون این دو تیکه معمولاً segment‌های RAW جدا از هم می‌مونن (مثلاً چون مجموع مدت‌زمانشون از MAX_DISPLAY بیشتره و merge_segments نمی‌تونه merge‌شون کنه)، این مرز اشتباه هیچ‌وقت به merge_segments یا split_long_segment نمی‌رسه که اصلاح بشه. این تابع، قبل از هر پردازش دیگه‌ای، مرز بین segmentهای پشت‌سرهم رو از دو جهت چک می‌کنه: جهتِ ۱ (رو به جلو): اگه segment اول با یه یا چند کلمه‌ی «ضعیف» و بدون علامت نگارشی (in/my/this/your/...) تموم بشه، اون کلمه(ها) به ابتدای segment بعدی منتقل میشن. مثال: «...building. In my» + «opinion, ...» → «...building.» + «In my opinion, ...» جهتِ ۲ (رو به عقب): اگه segment اول هنوز به یه پایانِ جمله‌ی واقعی (./!/؟) نرسیده باشه، و ابتدای segment بعدی یه دنباله‌ی کوتاهه (حداکثر چند کلمه) که زود به یه پایانِ جمله‌ی واقعی می‌رسه، اون دنباله به آخر segment اول منتقل میشه — چون یعنی داره همون جمله‌ی ناتمومِ قبلی رو کامل می‌کنه، نه اینکه جمله‌ی جدیدی باشه. مثال: «...TVs and other» + «electronics. I am curious...» → «...TVs and other electronics.» + «I am curious...» هر دو جهت فقط بین segmentهای هم‌گوینده (یا وقتی گوینده مشخص نیست) و با فاصله‌ی کوتاه انجام میشن. """ segments = [dict(s, words=list(s.get("words", []))) for s in segments] for i in range(len(segments) - 1): cur = segments[i] nxt = segments[i + 1] cur_words = cur["words"] nxt_words = nxt["words"] if not cur_words or not nxt_words: continue # فقط بین segmentهای هم‌گوینده جابه‌جا کن (اگه گوینده مشخص نبود، اجازه بده) if cur.get("speaker") is not None and nxt.get("speaker") is not None \ and cur.get("speaker") != nxt.get("speaker"): continue # فقط اگه فاصله کوتاهه، یعنی احتمالاً گفتار پیوسته‌ست نه یه مکث واقعی gap = nxt_words[0]["start"] - cur_words[-1]["end"] if gap > MAX_GAP: continue moved_any = False # جهتِ ۱: کلمه‌ی ضعیفِ آخرِ cur رو به جلو بفرست while len(cur_words) > 1: last = cur_words[-1] stripped = last["word"].strip() bare = stripped.strip(".,!?;:").lower() has_punct = bool(stripped) and stripped[-1] in {'.', '!', '?', ',', ';', ':'} if bare in WEAK_ENDINGS and not has_punct: nxt_words.insert(0, cur_words.pop()) moved_any = True else: break # جهتِ ۲: اگه cur هنوز به هیچ علامتِ نگارشیِ طبیعی‌ای (نه حتی ویرگول) نرسیده، دنباله‌ی # ابتدای nxt رو — اگه زود به یه ویرگول یا پایانِ جمله‌ی واقعی می‌رسه — به عقب بکش، # چون یعنی این دنباله داره همون عبارتِ ناتمومِ قبلی رو کامل می‌کنه، نه اینکه بخشِ # جدیدی باشه. فقط اگه بعد از این جابه‌جایی، هنوز بشه با split_long_segment این # تیکه‌ی بزرگ‌شده رو درست شکست، انجامش بده. (بدون این چک نهایی، ممکنه یه تیکه‌ی # خیلی بلند و بدون هیچ نقطه‌ی شکستنِ باقی‌مونده بسازیم) PAUSE_CHARS = SENTENCE_ENDERS | {','} cur_last = cur_words[-1]["word"].strip() cur_at_pause = bool(cur_last) and cur_last[-1] in PAUSE_CHARS if not cur_at_pause and len(nxt_words) > 1: lead = [] lead_chars = 0 found_end = False for w in nxt_words: stripped = w["word"].strip() if len(lead) >= LEAD_WORD_BUDGET or lead_chars + len(stripped) + 1 > LEAD_CHAR_BUDGET: break lead.append(w) lead_chars += len(stripped) + 1 if stripped and stripped[-1] in PAUSE_CHARS: found_end = True break if found_end and len(lead) < len(nxt_words): trial_words = cur_words + lead trial_text = " ".join(w["word"].strip() for w in trial_words) trial_seg = {"text": trial_text, "start": cur_words[0]["start"], "end": lead[-1]["end"], "words": trial_words} trial_pieces = split_long_segment(trial_seg) if all(fits_in_two_lines(p["text"]) is not None for p in trial_pieces): for _ in lead: cur_words.append(nxt_words.pop(0)) moved_any = True # فقط وقتی واقعاً کلمه‌ای جابه‌جا شد، text/start/end رو بازسازی کن — # مرزهایی که دست‌نخورده می‌مونن نباید حتی به‌خاطر گرد‌شدگی جزئی تغییر کنن if moved_any: cur["text"] = " ".join(w["word"].strip() for w in cur_words) cur["end"] = cur_words[-1]["end"] nxt["text"] = " ".join(w["word"].strip() for w in nxt_words) nxt["start"] = nxt_words[0]["start"] return segments def merge_segments(segments: list) -> list: if not segments: return [] merged = [] current = None for seg in segments: if current is None: current = { "text": seg["text"].strip(), "start": seg["start"], "end": seg["end"], "words": list(seg.get("words", [])), "speaker": seg.get("speaker"), } continue gap = seg["start"] - current["end"] combined_text = current["text"] + " " + seg["text"].strip() combined_duration = seg["end"] - current["start"] ends_sentence = current["text"].rstrip().endswith((".", "!", "?")) current_duration = current["end"] - current["start"] is_short = current_duration < 1.5 same_speaker = current.get("speaker") is None or current.get("speaker") == seg.get("speaker") too_fast = (len(current["text"]) / current_duration) > CPS_LIMIT if current_duration > 0 else True fits_dialogue = len(current["text"]) <= WRAP_CHARS and len(seg["text"].strip()) <= WRAP_CHARS already_dialogue = current.get("is_dialogue_block", False) should_merge = ( not ends_sentence and combined_duration <= MAX_DISPLAY and same_speaker and gap <= MAX_GAP and ( is_short or len(combined_text) <= MERGE_MAX_CHARS ) ) should_pair_as_dialogue = ( not already_dialogue and not same_speaker and too_fast and gap <= MAX_GAP and fits_dialogue ) if should_merge: current["text"] = combined_text current["end"] = seg["end"] current["words"] += list(seg.get("words", [])) elif should_pair_as_dialogue: current["text"] = f"- {current['text']}\n- {seg['text'].strip()}" current["end"] = seg["end"] current["words"] += list(seg.get("words", [])) current["is_dialogue_block"] = True else: merged.append(current) current = { "text": seg["text"].strip(), "start": seg["start"], "end": seg["end"], "words": list(seg.get("words", [])), "speaker": seg.get("speaker"), } if current: merged.append(current) return merged def remove_duplicates(segments: list) -> list: if not segments: return [] result = [segments[0]] for seg in segments[1:]: if seg["text"].strip() != result[-1]["text"].strip(): result.append(seg) return result def fix_overlaps(segments: list) -> list: fixed = [] for seg in segments: seg = dict(seg) if fixed and seg["start"] < fixed[-1]["end"]: seg["start"] = fixed[-1]["end"] + 0.001 if seg["start"] >= seg["end"]: seg["end"] = seg["start"] + MIN_DISPLAY fixed.append(seg) return fixed def apply_min_display(segments: list) -> list: result = [] for i, seg in enumerate(segments): seg = dict(seg) duration = seg["end"] - seg["start"] if duration < MIN_DISPLAY: if i + 1 < len(segments): next_start = segments[i + 1]["start"] max_end = next_start - 0.080 else: max_end = seg["end"] + MAX_DISPLAY new_end = seg["start"] + MIN_DISPLAY seg["end"] = min(new_end, max(max_end, seg["end"])) result.append(seg) return result def split_long_segment(seg: dict) -> list: """ فقط اگه punctuation نزدیک وسط جمله بود، به دو subtitle جداگانه بشکن. اگه نقطه مناسبی نبود، نشکن — wrap_text در build_srt دو خطش می‌کنه. بلوک‌های دیالوگِ دو-گوینده‌ای (is_dialogue_block) هیچ‌وقت شکسته نمی‌شن. """ if seg.get("is_dialogue_block"): return [seg] words = seg.get("words", []) text = seg["text"].strip() punct_chars = {'.', '!', '?', ',', ';', ':'} sentence_enders = {'.', '!', '?'} min_part_chars = 10 # حتی اگه از سقفِ طول رد نشده باشیم، اگه یه پایانِ جمله‌ی واقعی *وسطِ* متن باشه # (نه فقط کلمه‌ی آخر)، یعنی این تیکه در واقع بیشتر از یه جمله‌ست و باید بشکنه — # یه cue نباید از رو یه نقطه‌ی واقعی رد بشه، فارغ از اینکه کلاً چقدر کوتاهه has_internal_sentence_end = any( w["word"].strip() and w["word"].strip()[-1] in sentence_enders for w in words[:-1] ) if not has_internal_sentence_end and (len(words) < 4 or fits_in_two_lines(text) is not None): return [seg] full_text = text mid_char = len(full_text) / 2 best_idx = -1 best_score = float('-inf') char_pos = 0 for i, w in enumerate(words): word_stripped = w["word"].strip() this_word_end_char = char_pos + len(word_stripped) if word_stripped and word_stripped[-1] in punct_chars and 0 < i < len(words) - 1: # چک درست: خودِ کلمه‌ای که این علامت نگارشی روشه، نباید یه کلمه‌ی ضعیف باشه bare_word = word_stripped.strip(".,!?;:").lower() part1_chars = this_word_end_char part2_chars = len(full_text) - this_word_end_char if bare_word not in WEAK_ENDINGS and part1_chars >= min_part_chars and part2_chars >= min_part_chars: dist_from_mid = abs(this_word_end_char - mid_char) proximity_score = 1.0 - (dist_from_mid / len(full_text)) sentence_end_bonus = 0.5 if word_stripped[-1] in sentence_enders else 0.0 score = proximity_score + sentence_end_bonus if score > best_score: best_score = score best_idx = i + 1 char_pos = this_word_end_char + 1 # هیچ نقطه‌ی شکستنِ مبتنی‌بر‌نگارش مناسبی پیدا نشد if best_idx == -1: # اگه متن همینطوری هم تو ۲ خطِ استاندارد جا میشه، مشکلی نیست، دست‌نخورده برگردون if fits_in_two_lines(full_text) is not None: return [seg] # ولی اگه ذاتاً برای یه cueِ دوخطی خیلی بلنده و هیچ ویرگول/نقطه‌ای هم نداره که # ازش کمک بگیریم، باید یه جای معقول (نزدیکِ وسط، رو مرزِ کلمه) بشکنیمش — # وگرنه یه cueِ غیرِمنطبق با استاندارد (خطی بیشتر از ۴۲ کاراکتر) تحویل می‌دیم. # این دقیقاً همون کاری‌یه که زیرنویس‌سازهای حرفه‌ای هم می‌کنن وقتی جمله‌ی # بی‌وقفه و بی‌ویرگولی رو باید بشکنن: رو یه مرزِ طبیعیِ کلمه، نزدیکِ وسط. best_idx = None best_diff = float('inf') semantic_idx = None semantic_diff = float('inf') LINE_START_OK = WEAK_ENDINGS | {"and", "or", "but", "because", "so", "although", "which", "who", "that", "when", "while", "if"} for i in range(2, len(words) - 1): part1 = " ".join(w["word"].strip() for w in words[:i]) part2 = " ".join(w["word"].strip() for w in words[i:]) if fits_in_two_lines(part1) is not None and fits_in_two_lines(part2) is not None: diff = abs(len(part1) - len(part2)) if diff < best_diff: best_diff = diff best_idx = i # اولویت با نقطه‌ای که رو یه مرزِ نگارشی/معنایی معقول باشه — # یعنی کلمه‌ی قبل از شکستن با علامتِ نگارشی تموم بشه، یا کلمه‌ی # بعد از شکستن یه ربط‌دهنده باشه — نه وسطِ یه عبارتِ چسبیده prev_bare = words[i - 1]["word"].strip() next_bare = words[i]["word"].strip().strip(".,!?;:").lower() is_semantic = (prev_bare and prev_bare[-1] in punct_chars) or (next_bare in LINE_START_OK) if is_semantic and diff < semantic_diff: semantic_diff = diff semantic_idx = i if semantic_idx is not None: best_idx = semantic_idx if best_idx is None: return [seg] words1 = words[:best_idx] words2 = words[best_idx:] if len(words1) < 2 or len(words2) < 2: return [seg] start2 = words2[0]["start"] end1 = max(start2 - 0.080, words1[-1]["end"]) if end1 - words1[0]["start"] < MIN_DISPLAY: end1 = words1[0]["start"] + MIN_DISPLAY if end1 >= start2: end1 = start2 - 0.040 seg1 = { "text": " ".join(w["word"].strip() for w in words1), "start": words1[0]["start"], "end": end1, "words": words1, } seg2 = { "text": " ".join(w["word"].strip() for w in words2), "start": start2, "end": words2[-1]["end"], "words": words2, } return split_long_segment(seg1) + split_long_segment(seg2) def merge_short_fragments(segments: list) -> list: """ بعد از شکستنِ جمله‌های بلند، بعضی‌وقتا یه تیکه‌ی خیلی کوچیک باقی می‌مونه که مدت‌زمان نمایشش زیر MIN_DISPLAY (۱.۵ ثانیه) میشه — نه چون واقعاً یه عبارت مستقله، بلکه چون نقطه‌ی شکستن (punctuation) دقیقاً همون‌جا بوده. این تابع، به‌صورت یه پاسِ نهایی، هر تیکه‌ی زیر ۱.۵ ثانیه رو در صورت امکان با همسایه‌ش (قبلی یا بعدی) یکی می‌کنه — فقط وقتی که ادغام از سقف‌های نمایشی (MAX_DISPLAY و طول قابل‌قبول برای دو خط) رد نشه، از رو یه پایانِ جمله‌ی واقعی (./!/؟) عبور نکنه، و از همه مهم‌تر: فاصله‌ی زمانیِ واقعی بین دو تیکه از MAX_GAP بیشتر نباشه — یعنی اگه یه کلمه یا عبارتِ کوتاه چند ثانیه بعد از تیکه‌ی قبلی پخش میشه (یه مکثِ واقعی وسطشونه)، حتی با اینکه کوتاهه، مستقل می‌مونه و به تیکه‌ی قبلی نمی‌چسبه. نکته: این یه قانونِ کاملاً مکانیکیه (فقط بر اساس مدت‌زمان)، نه یه تشخیص معنایی از اینکه عبارت «کامل» هست یا نه — پس ممکنه عبارت‌های کوتاهِ ولی کاملی (مثل «For example,») رو هم با همسایه‌شون ادغام کنه. یه نکته‌ی مهمِ دیگه: این تابع همیشه هر دو جهت (چسبیدن به تیکه‌ی قبلی، یا چسبیدن به تیکه‌ی بعدی) رو امتحان می‌کنه و هر کدوم که ترکیبِ کوتاه‌تر (جادارتر) بسازه رو انتخاب می‌کنه — نه اینکه همیشه فقط به عقب بچسبونه. چون گاهی چسبوندنِ حریصانه به عقب باعث میشه یه تیکه‌ی کوتاهِ *بعدی* بی‌جا بمونه (چون هر دو همسایه‌ش پر شدن)، در حالی که اگه از اول به جلو می‌چسبید، هیچ‌کدوم بی‌جا نمی‌موندن. """ if not segments: return [] segments = [dict(s) for s in segments] def can_merge(a: dict, b: dict) -> tuple: """آیا a و b قابل ادغامن؟ اگه بله، (True, متنِ ترکیبی، طولِ متنِ ترکیبی) برمی‌گردونه.""" if a["text"].rstrip().endswith((".", "!", "?")): return False, None, None gap = b["start"] - a["end"] if gap > MAX_GAP: return False, None, None combined_duration = b["end"] - a["start"] if combined_duration > MAX_DISPLAY: return False, None, None combined_text = a["text"].rstrip() + " " + b["text"].lstrip() if fits_in_two_lines(combined_text) is None: return False, None, None return True, combined_text, len(combined_text) def do_merge(a: dict, b: dict, combined_text: str) -> dict: merged = dict(a) merged["text"] = combined_text merged["end"] = b["end"] if "words" in a or "words" in b: merged["words"] = list(a.get("words", [])) + list(b.get("words", [])) return merged result = [] i = 0 n = len(segments) while i < n: seg = segments[i] seg_duration = seg["end"] - seg["start"] if seg_duration >= MIN_DISPLAY: result.append(seg) i += 1 continue prev = result[-1] if result else None nxt = segments[i + 1] if i + 1 < n else None back_ok, back_text, back_len = (can_merge(prev, seg) if prev is not None else (False, None, None)) fwd_ok, fwd_text, fwd_len = (can_merge(seg, nxt) if nxt is not None else (False, None, None)) if back_ok and (not fwd_ok or back_len <= fwd_len): merged = do_merge(prev, seg, back_text) result[-1] = merged i += 1 elif fwd_ok: merged = do_merge(seg, nxt, fwd_text) result.append(merged) i += 2 else: result.append(seg) i += 1 return result def fits_in_two_lines(text: str) -> str | None: """ طبقِ استانداردِ واقعیِ زیرنویس (Netflix/Amazon/Disney+: حداکثر ۴۲ کاراکتر در هر خط، حداکثر ۲ خط در هر cue)، سعی می‌کنه متن رو به ۱ یا ۲ خط بشکنه طوری که هیچ‌کدوم از خط‌ها از WRAP_CHARS بیشتر نشه. بینِ نقاطِ معتبر (هر دو خط ≤۴۲)، صرفاً «متعادل‌ترین از نظرِ طول» رو انتخاب نمی‌کنه — اول دنبالِ نقطه‌ای می‌گرده که از نظرِ نگارشی/معنایی هم معقول باشه (بعدِ یه ویرگول یا علامتِ نگارشی، یا لااقل قبل از یه کلمه‌ی ربط‌دهنده مثل and/or/because)، تا یه عبارتِ چسبیده (مثل «certain locations») وسط بریده نشه. فقط وقتی هیچ‌کدومِ نقاطِ معتبر چنین ویژگی‌ای نداشتن، سراغِ متعادل‌ترینِ صرفاً از نظرِ طول می‌ره. اگه ممکن باشه، متنِ شکسته‌شده (یا خودِ متن، اگه از اول یه‌خطی بود) رو برمی‌گردونه. اگه هیچ نقطه‌ی شکستنی وجود نداشته باشه که هر دو خط رو زیرِ ۴۲ نگه داره، None برمی‌گردونه — یعنی این متن ذاتاً برای یه cueِ دوخطی خیلی بلنده و باید به چند subtitleِ جدا تقسیم بشه؛ این تشخیص با یه عددِ اختیاریِ حدسی (مثل «۹۴ کاراکتر») جایگزین نمیشه. """ if len(text) <= WRAP_CHARS: return text words = text.split() # کلمه‌هایی که اگه یه خط باهاشون شروع بشه، یعنی نقطه‌ی شکستنِ قبلشون از نظرِ # نگارشی/معنایی معقوله (چه به‌خاطرِ ویرگول/نقطه‌ی قبلش، چه چون خودش یه ربط‌دهنده‌ست) LINE_START_OK = WEAK_ENDINGS | {"and", "or", "but", "because", "so", "although", "which", "who", "that", "when", "while", "if"} valid = [] # (i, line1, line2, diff) for i in range(1, len(words)): line1 = " ".join(words[:i]) line2 = " ".join(words[i:]) if len(line1) <= WRAP_CHARS and len(line2) <= WRAP_CHARS: diff = abs(len(line1) - len(line2)) valid.append((i, line1, line2, diff)) if not valid: return None def is_semantically_ok(i: int, line1: str) -> bool: # یا خودِ کلمه‌ی قبل از نقطه‌ی شکستن با علامتِ نگارشی تموم میشه (ویرگول/نقطه/...)، # یا خطِ بعدی با یه کلمه‌ی ربط‌دهنده/ضعیف شروع میشه — یعنی این خط یه عبارتِ # چسبیده‌ی وسط‌بریده نیست prev_word = words[i - 1].strip() if prev_word and prev_word[-1] in {',', '.', '!', '?', ';', ':'}: return True next_word_bare = words[i].strip(".,!?;:").lower() return next_word_bare in LINE_START_OK semantically_ok = [v for v in valid if is_semantically_ok(v[0], v[1])] pool = semantically_ok if semantically_ok else valid best = min(pool, key=lambda v: v[3]) return f"{best[1]}\n{best[2]}" def wrap_text(text: str) -> str: """متن رو طبقِ قانونِ واقعیِ ۴۲کاراکتری به ۱ یا ۲ خط می‌شکنه. اگه متن از قبل یه بلوک دیالوگِ دو-گوینده‌ای (با - و خط جدید) بود، دست‌نخورده برگردون. اگه (که نباید پیش بیاد، چون split_long_segment از قبل تضمین کرده) هیچ شکستنِ معتبری پیدا نشد، به‌جای گم‌کردنِ متن، همون شکستنِ نزدیک‌به‌وسطِ قدیمی رو به‌عنوانِ آخرین راه‌حل می‌زنیم.""" if text.startswith("- ") and "\n" in text: return text fitted = fits_in_two_lines(text) if fitted is not None: return fitted # fallback نادر: وقتی هیچ شکستنی زیرِ ۴۲ پیدا نشه (نباید معمولاً پیش بیاد) words = text.split() best_break = -1 best_diff = float('inf') current_len = 0 for i, word in enumerate(words): current_len += len(word) + (1 if i > 0 else 0) diff = abs(current_len - len(text) / 2) if diff < best_diff: best_diff = diff best_break = i + 1 if best_break <= 0 or best_break >= len(words): return text line1 = " ".join(words[:best_break]) line2 = " ".join(words[best_break:]) return f"{line1}\n{line2}" def build_srt(segments: list) -> str: lines = [] index = 1 for seg in segments: text = seg["text"].strip() if not text: continue start_tc = format_timestamp(seg["start"]) end_tc = format_timestamp(seg["end"]) wrapped = wrap_text(text) lines.append(f"{index}\n{start_tc} --> {end_tc}\n{wrapped}") index += 1 return "\n\n".join(lines) + "\n" def process(segments: list) -> str: if not segments: return "" segments = rebalance_boundaries(segments) merged = merge_segments(segments) merged = remove_duplicates(merged) fixed = fix_overlaps(merged) split = [] for seg in fixed: split.extend(split_long_segment(seg)) split = fix_overlaps(split) split = merge_short_fragments(split) split = fix_overlaps(split) final = apply_min_display(split) return build_srt(final)