AliJabbarpour commited on
Commit
0377ffa
·
verified ·
1 Parent(s): bb7579f

Upload postprocess.py

Browse files
Files changed (1) hide show
  1. pipeline/postprocess.py +492 -0
pipeline/postprocess.py ADDED
@@ -0,0 +1,492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─── Post-Processing Engine v10 ────────────────────────────────────────────
2
+
3
+ MIN_DISPLAY = 1.5
4
+ MAX_DISPLAY = 7.0
5
+ MERGE_MAX_CHARS = 72
6
+ MAX_GAP = 1.5
7
+ MAX_LINE_CHARS = 94 # سقفِ واقعیِ ظرفیتِ یه cue با دو خطِ نمایشی (2×WRAP_CHARS + کمی حاشیه) —
8
+ # فقط وقتی از این رد بشیم split_long_segment به دو subtitle جدا می‌شکنه؛
9
+ # قبلاً ۶۰ بود که خیلی پایین‌تر از ظرفیتِ واقعی بود و باعث می‌شد
10
+ # جمله‌هایی که کاملاً تو یه cue جا می‌شدن، بی‌دلیل به دو تیکه بشکنن
11
+ WRAP_CHARS = 42 # استاندارد Netflix برای wrap به دو خط
12
+ CPS_LIMIT = 17 # حداکثر کاراکتر قابل‌خواندن در ثانیه - برای تشخیص جمله‌ی «به‌سرعت رد شده»
13
+
14
+ # کلمه‌های کوچیک و به‌هم‌چسبیده‌ای که نباید تنها و جدا از کلمه‌ی بعدشون
15
+ # ته یه segment (چه raw، چه بعد از split) بمونن.
16
+ # هم توی split_long_segment استفاده میشه، هم توی rebalance_boundaries.
17
+ WEAK_ENDINGS = {
18
+ 'in', 'my', 'your', 'his', 'her', 'our', 'their', 'its', 'the', 'a', 'an',
19
+ 'of', 'to', 'at', 'on', 'by', 'for', 'with', 'is', 'are', 'was', 'were',
20
+ 'and', 'or', 'but', 'so', 'if', 'that', 'this', 'we', 'you', 'he', 'she', 'they',
21
+ }
22
+
23
+
24
+ def format_timestamp(seconds: float) -> str:
25
+ ms = round((seconds % 1) * 1000)
26
+ s = int(seconds) % 60
27
+ m = int(seconds // 60) % 60
28
+ h = int(seconds // 3600)
29
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
30
+
31
+
32
+ SENTENCE_ENDERS = {'.', '!', '?'}
33
+ LEAD_WORD_BUDGET = 9 # حداکثر تعداد کلمه‌ای که اجازه داریم از ابتدای segment بعدی به عقب بکشیم
34
+ LEAD_CHAR_BUDGET = 55 # حداکثر تعداد کاراکتر همون دنباله
35
+ MAX_REBALANCE_PIECE_CHARS = WRAP_CHARS * 2 + 10 # همون سقفِ استانداردِ یه cue دو‌خطی (۹۴ کاراکتر) — نه یه عددِ اختیاریِ جدا
36
+
37
+
38
+ def rebalance_boundaries(segments: list) -> list:
39
+ """
40
+ مشکلی که این تابع حل می‌کنه: خودِ Whisper بعضی‌وقتا وسط یه عبارت به‌هم‌چسبیده
41
+ (مثل «in my | opinion» یا «of this | video») مرز segment می‌ذاره — نه سر یه
42
+ مکث طبیعی. چون این دو تیکه معمولاً segment‌های RAW جدا از هم می‌مونن (مثلاً چون
43
+ مجموع مدت‌زمانشون از MAX_DISPLAY بیشتره و merge_segments نمی‌تونه merge‌شون کنه)،
44
+ این مرز اشتباه هیچ‌وقت به merge_segments یا split_long_segment نمی‌رسه که اصلاح بشه.
45
+
46
+ این تابع، قبل از هر پردازش دیگه‌ای، مرز بین segmentهای پشت‌سرهم رو از دو جهت چک می‌کنه:
47
+
48
+ جهتِ ۱ (رو به جلو): اگه segment اول با یه یا چند کلمه‌ی «ضعیف» و بدون علامت نگارشی
49
+ (in/my/this/your/...) تموم بشه، اون کلمه(ها) به ابتدای segment بعدی منتقل میشن.
50
+ مثال: «...building. In my» + «opinion, ...» → «...building.» + «In my opinion, ...»
51
+
52
+ جهتِ ۲ (رو به عقب): اگه segment اول هنوز به یه پایانِ جمله‌ی واقعی (./!/؟) نرسیده باشه،
53
+ و ابتدای segment بعدی یه دنباله‌ی کوتاهه (حداکثر چند کلمه) که زود به یه پایانِ جمله‌ی
54
+ واقعی می‌رسه، اون دنباله به آخر segment اول منتقل میشه — چون یعنی داره همون جمله‌ی
55
+ ناتمومِ قبلی رو کامل می‌کنه، نه اینکه جمله‌ی جدیدی باشه.
56
+ مثال: «...TVs and other» + «electronics. I am curious...» → «...TVs and other electronics.» + «I am curious...»
57
+
58
+ هر دو جهت فقط بین segmentهای هم‌گوینده (یا وقتی گوینده مشخص نیست) و با فاصله‌ی کوتاه انجام میشن.
59
+ """
60
+ segments = [dict(s, words=list(s.get("words", []))) for s in segments]
61
+
62
+ for i in range(len(segments) - 1):
63
+ cur = segments[i]
64
+ nxt = segments[i + 1]
65
+
66
+ cur_words = cur["words"]
67
+ nxt_words = nxt["words"]
68
+ if not cur_words or not nxt_words:
69
+ continue
70
+
71
+ # فقط بین segmentهای هم‌گوینده جابه‌جا کن (اگه گوینده مشخص نبود، اجازه بده)
72
+ if cur.get("speaker") is not None and nxt.get("speaker") is not None \
73
+ and cur.get("speaker") != nxt.get("speaker"):
74
+ continue
75
+
76
+ # فقط اگه فاصله کوتاهه، یعنی احتمالاً گفتار پیوسته‌ست نه یه مکث واقعی
77
+ gap = nxt_words[0]["start"] - cur_words[-1]["end"]
78
+ if gap > MAX_GAP:
79
+ continue
80
+
81
+ moved_any = False
82
+
83
+ # جهتِ ۱: کلمه‌ی ضعیفِ آخرِ cur رو به جلو بفرست
84
+ while len(cur_words) > 1:
85
+ last = cur_words[-1]
86
+ stripped = last["word"].strip()
87
+ bare = stripped.strip(".,!?;:").lower()
88
+ has_punct = bool(stripped) and stripped[-1] in {'.', '!', '?', ',', ';', ':'}
89
+
90
+ if bare in WEAK_ENDINGS and not has_punct:
91
+ nxt_words.insert(0, cur_words.pop())
92
+ moved_any = True
93
+ else:
94
+ break
95
+
96
+ # جهتِ ۲: اگه cur هنوز به هیچ علامتِ نگارشیِ طبیعی‌ای (نه حتی ویرگول) نرسیده، دنباله‌ی
97
+ # ابتدای nxt رو — اگه زود به یه ویرگول یا پایانِ جمله‌ی واقعی می‌رسه — به عقب بکش،
98
+ # چون یعنی این دنباله داره همون عبارتِ ناتمومِ قبلی رو کامل می‌کنه، نه اینکه بخشِ
99
+ # جدیدی باشه. فقط اگه بعد از این جابه‌جایی، هنوز بشه با split_long_segment این
100
+ # تیکه‌ی بزرگ‌شده رو درست شکست، انجامش بده. (بدون این چک نهایی، ممکنه یه تیکه‌ی
101
+ # خیلی بلند و بدون هیچ نقطه‌ی شکستنِ باقی‌مونده بسازیم)
102
+ PAUSE_CHARS = SENTENCE_ENDERS | {','}
103
+ cur_last = cur_words[-1]["word"].strip()
104
+ cur_at_pause = bool(cur_last) and cur_last[-1] in PAUSE_CHARS
105
+
106
+ if not cur_at_pause and len(nxt_words) > 1:
107
+ lead = []
108
+ lead_chars = 0
109
+ found_end = False
110
+ for w in nxt_words:
111
+ stripped = w["word"].strip()
112
+ if len(lead) >= LEAD_WORD_BUDGET or lead_chars + len(stripped) + 1 > LEAD_CHAR_BUDGET:
113
+ break
114
+ lead.append(w)
115
+ lead_chars += len(stripped) + 1
116
+ if stripped and stripped[-1] in PAUSE_CHARS:
117
+ found_end = True
118
+ break
119
+
120
+ if found_end and len(lead) < len(nxt_words):
121
+ trial_words = cur_words + lead
122
+ trial_text = " ".join(w["word"].strip() for w in trial_words)
123
+ trial_seg = {"text": trial_text, "start": cur_words[0]["start"],
124
+ "end": lead[-1]["end"], "words": trial_words}
125
+ trial_pieces = split_long_segment(trial_seg)
126
+
127
+ if all(len(p["text"]) <= MAX_REBALANCE_PIECE_CHARS for p in trial_pieces):
128
+ for _ in lead:
129
+ cur_words.append(nxt_words.pop(0))
130
+ moved_any = True
131
+
132
+ # فقط وقتی واقعاً کلمه‌ای جابه‌جا شد، text/start/end رو بازسازی کن —
133
+ # مرزهایی که دست‌نخورده می‌مونن نباید حتی به‌خاطر گرد‌شدگی جزئی تغییر کنن
134
+ if moved_any:
135
+ cur["text"] = " ".join(w["word"].strip() for w in cur_words)
136
+ cur["end"] = cur_words[-1]["end"]
137
+ nxt["text"] = " ".join(w["word"].strip() for w in nxt_words)
138
+ nxt["start"] = nxt_words[0]["start"]
139
+
140
+ return segments
141
+
142
+
143
+ def merge_segments(segments: list) -> list:
144
+ if not segments:
145
+ return []
146
+
147
+ merged = []
148
+ current = None
149
+
150
+ for seg in segments:
151
+ if current is None:
152
+ current = {
153
+ "text": seg["text"].strip(),
154
+ "start": seg["start"],
155
+ "end": seg["end"],
156
+ "words": list(seg.get("words", [])),
157
+ "speaker": seg.get("speaker"),
158
+ }
159
+ continue
160
+
161
+ gap = seg["start"] - current["end"]
162
+ combined_text = current["text"] + " " + seg["text"].strip()
163
+ combined_duration = seg["end"] - current["start"]
164
+ ends_sentence = current["text"].rstrip().endswith((".", "!", "?"))
165
+ current_duration = current["end"] - current["start"]
166
+ is_short = current_duration < 1.5
167
+ same_speaker = current.get("speaker") is None or current.get("speaker") == seg.get("speaker")
168
+ too_fast = (len(current["text"]) / current_duration) > CPS_LIMIT if current_duration > 0 else True
169
+ fits_dialogue = len(current["text"]) <= WRAP_CHARS and len(seg["text"].strip()) <= WRAP_CHARS
170
+ already_dialogue = current.get("is_dialogue_block", False)
171
+
172
+ should_merge = (
173
+ not ends_sentence
174
+ and combined_duration <= MAX_DISPLAY
175
+ and same_speaker
176
+ and gap <= MAX_GAP
177
+ and (
178
+ is_short
179
+ or len(combined_text) <= MERGE_MAX_CHARS
180
+ )
181
+ )
182
+
183
+ should_pair_as_dialogue = (
184
+ not already_dialogue
185
+ and not same_speaker
186
+ and too_fast
187
+ and gap <= MAX_GAP
188
+ and fits_dialogue
189
+ )
190
+
191
+ if should_merge:
192
+ current["text"] = combined_text
193
+ current["end"] = seg["end"]
194
+ current["words"] += list(seg.get("words", []))
195
+ elif should_pair_as_dialogue:
196
+ current["text"] = f"- {current['text']}\n- {seg['text'].strip()}"
197
+ current["end"] = seg["end"]
198
+ current["words"] += list(seg.get("words", []))
199
+ current["is_dialogue_block"] = True
200
+ else:
201
+ merged.append(current)
202
+ current = {
203
+ "text": seg["text"].strip(),
204
+ "start": seg["start"],
205
+ "end": seg["end"],
206
+ "words": list(seg.get("words", [])),
207
+ "speaker": seg.get("speaker"),
208
+ }
209
+
210
+ if current:
211
+ merged.append(current)
212
+
213
+ return merged
214
+
215
+
216
+ def remove_duplicates(segments: list) -> list:
217
+ if not segments:
218
+ return []
219
+ result = [segments[0]]
220
+ for seg in segments[1:]:
221
+ if seg["text"].strip() != result[-1]["text"].strip():
222
+ result.append(seg)
223
+ return result
224
+
225
+
226
+ def fix_overlaps(segments: list) -> list:
227
+ fixed = []
228
+ for seg in segments:
229
+ seg = dict(seg)
230
+ if fixed and seg["start"] < fixed[-1]["end"]:
231
+ seg["start"] = fixed[-1]["end"] + 0.001
232
+ if seg["start"] >= seg["end"]:
233
+ seg["end"] = seg["start"] + MIN_DISPLAY
234
+ fixed.append(seg)
235
+ return fixed
236
+
237
+
238
+ def apply_min_display(segments: list) -> list:
239
+ result = []
240
+ for i, seg in enumerate(segments):
241
+ seg = dict(seg)
242
+ duration = seg["end"] - seg["start"]
243
+ if duration < MIN_DISPLAY:
244
+ if i + 1 < len(segments):
245
+ next_start = segments[i + 1]["start"]
246
+ max_end = next_start - 0.080
247
+ else:
248
+ max_end = seg["end"] + MAX_DISPLAY
249
+ new_end = seg["start"] + MIN_DISPLAY
250
+ seg["end"] = min(new_end, max(max_end, seg["end"]))
251
+ result.append(seg)
252
+ return result
253
+
254
+
255
+ def split_long_segment(seg: dict) -> list:
256
+ """
257
+ فقط اگه punctuation نزدیک وسط جمله بود، به دو subtitle جداگانه بشکن.
258
+ اگه نقطه مناسبی نبود، نشکن — wrap_text در build_srt دو خطش می‌کنه.
259
+ بلوک‌های دیالوگِ دو-گوینده‌ای (is_dialogue_block) هیچ‌وقت شکسته نمی‌شن.
260
+ """
261
+ if seg.get("is_dialogue_block"):
262
+ return [seg]
263
+
264
+ words = seg.get("words", [])
265
+ text = seg["text"].strip()
266
+
267
+ punct_chars = {'.', '!', '?', ',', ';', ':'}
268
+ sentence_enders = {'.', '!', '?'}
269
+ min_part_chars = 10
270
+
271
+ # حتی اگه از سقفِ طول رد نشده باشیم، اگه یه پایانِ جمله‌ی واقعی *وسطِ* متن باشه
272
+ # (نه فقط کلمه‌ی آخر)، یعنی این تیکه در واقع بیشتر از یه جمله‌ست و باید بشکنه —
273
+ # یه cue نباید از رو یه نقطه‌ی واقعی رد بشه، فارغ از اینکه کلاً چقدر کوتاهه
274
+ has_internal_sentence_end = any(
275
+ w["word"].strip() and w["word"].strip()[-1] in sentence_enders
276
+ for w in words[:-1]
277
+ )
278
+
279
+ if not has_internal_sentence_end and (len(text) <= MAX_LINE_CHARS or len(words) < 4):
280
+ return [seg]
281
+
282
+ full_text = text
283
+ mid_char = len(full_text) / 2
284
+
285
+ best_idx = -1
286
+ best_score = float('-inf')
287
+ char_pos = 0
288
+
289
+ for i, w in enumerate(words):
290
+ word_stripped = w["word"].strip()
291
+ this_word_end_char = char_pos + len(word_stripped)
292
+
293
+ if word_stripped and word_stripped[-1] in punct_chars and 0 < i < len(words) - 1:
294
+ # چک درست: خودِ کلمه‌ای که این علامت نگارشی روشه، نباید یه کلمه‌ی ضعیف باشه
295
+ bare_word = word_stripped.strip(".,!?;:").lower()
296
+ part1_chars = this_word_end_char
297
+ part2_chars = len(full_text) - this_word_end_char
298
+
299
+ if bare_word not in WEAK_ENDINGS and part1_chars >= min_part_chars and part2_chars >= min_part_chars:
300
+ dist_from_mid = abs(this_word_end_char - mid_char)
301
+ proximity_score = 1.0 - (dist_from_mid / len(full_text))
302
+ sentence_end_bonus = 0.5 if word_stripped[-1] in sentence_enders else 0.0
303
+ score = proximity_score + sentence_end_bonus
304
+
305
+ if score > best_score:
306
+ best_score = score
307
+ best_idx = i + 1
308
+
309
+ char_pos = this_word_end_char + 1
310
+
311
+ # هیچ نقطه‌ی شکستن مناسبی پیدا نشد → نشکن، wrap_text کافیه
312
+ if best_idx == -1:
313
+ return [seg]
314
+
315
+ words1 = words[:best_idx]
316
+ words2 = words[best_idx:]
317
+
318
+ if len(words1) < 2 or len(words2) < 2:
319
+ return [seg]
320
+
321
+ start2 = words2[0]["start"]
322
+ end1 = max(start2 - 0.080, words1[-1]["end"])
323
+
324
+ if end1 - words1[0]["start"] < MIN_DISPLAY:
325
+ end1 = words1[0]["start"] + MIN_DISPLAY
326
+ if end1 >= start2:
327
+ end1 = start2 - 0.040
328
+
329
+ seg1 = {
330
+ "text": " ".join(w["word"].strip() for w in words1),
331
+ "start": words1[0]["start"],
332
+ "end": end1,
333
+ "words": words1,
334
+ }
335
+ seg2 = {
336
+ "text": " ".join(w["word"].strip() for w in words2),
337
+ "start": start2,
338
+ "end": words2[-1]["end"],
339
+ "words": words2,
340
+ }
341
+
342
+ return split_long_segment(seg1) + split_long_segment(seg2)
343
+
344
+
345
+ def merge_short_fragments(segments: list) -> list:
346
+ """
347
+ بعد از شکستنِ جمله‌های بلند، بعضی‌وقتا یه تیکه‌ی خیلی کوچیک باقی می‌مونه که مدت‌زمان
348
+ نمایشش زیر MIN_DISPLAY (۱.۵ ثانیه) میشه — نه چون واقعاً یه عبارت مستقله، بلکه چون
349
+ نقطه‌ی شکستن (punctuation) دقیقاً همون‌جا بوده. این تابع، به‌صورت یه پاسِ نهایی،
350
+ هر تیکه‌ی زیر ۱.۵ ثانیه رو در صورت امکان با همسایه‌ش (قبلی یا بعدی) یکی می‌کنه —
351
+ فقط وقتی که ادغام از سقف‌های نمایشی (MAX_DISPLAY و طول قابل‌قبول برای دو خط) رد نشه،
352
+ از رو یه پایانِ جمله‌ی واقعی (./!/؟) عبور نکنه، و از همه مهم‌تر: فاصله‌ی زمانیِ واقعی
353
+ بین دو تیکه از MAX_GAP بیشتر نباشه — یعنی اگه یه کلمه یا عبارتِ کوتاه چند ثانیه بعد از
354
+ تیکه‌ی قبلی پخش میشه (یه مکثِ واقعی وسطشونه)، حتی با اینکه کوتاهه، مستقل می‌مونه و به
355
+ تیکه‌ی قبلی نمی‌چسبه.
356
+
357
+ نکته: این یه قانونِ کاملاً مکانیکیه (فقط بر اساس مدت‌زمان)، نه یه تشخیص معنایی از
358
+ اینکه عبارت «کامل» هست یا نه — پس ممکنه عبارت‌های کوتاهِ ولی کاملی (مثل «For example,»)
359
+ رو هم با همسایه‌شون ادغام کنه.
360
+
361
+ یه نکته‌ی مهمِ دیگه: این تابع همیشه هر دو جهت (چسبیدن به تیکه‌ی قبلی، یا چسبیدن به
362
+ تیکه‌ی بعدی) رو امتحان می‌کنه و هر کدوم که ترکیبِ کوتاه‌تر (جادارتر) بسازه رو انتخاب
363
+ می‌کنه — نه اینکه همیشه فقط به عقب بچسبونه. چون گاهی چسبوندنِ حریصانه به عقب باعث
364
+ میشه یه تیکه‌ی کوتاهِ *بعدی* بی‌جا بمونه (چون هر دو همسایه‌ش پر شدن)، در حالی که اگه
365
+ از اول به جلو می‌چسبید، هیچ‌کدوم بی‌جا نمی‌موندن.
366
+ """
367
+ if not segments:
368
+ return []
369
+
370
+ MAX_MERGED_CHARS = WRAP_CHARS * 2 + 10 # فضای قابل‌قبول برای یه cue دو‌خطی
371
+
372
+ segments = [dict(s) for s in segments]
373
+
374
+ def can_merge(a: dict, b: dict) -> tuple:
375
+ """آیا a و b قابل ادغامن؟ اگه بله، (True, متنِ ترکیبی، طولِ متنِ ترکیبی) برمی‌گردونه."""
376
+ if a["text"].rstrip().endswith((".", "!", "?")):
377
+ return False, None, None
378
+ gap = b["start"] - a["end"]
379
+ if gap > MAX_GAP:
380
+ return False, None, None
381
+ combined_duration = b["end"] - a["start"]
382
+ if combined_duration > MAX_DISPLAY:
383
+ return False, None, None
384
+ combined_text = a["text"].rstrip() + " " + b["text"].lstrip()
385
+ if len(combined_text) > MAX_MERGED_CHARS:
386
+ return False, None, None
387
+ return True, combined_text, len(combined_text)
388
+
389
+ def do_merge(a: dict, b: dict, combined_text: str) -> dict:
390
+ merged = dict(a)
391
+ merged["text"] = combined_text
392
+ merged["end"] = b["end"]
393
+ if "words" in a or "words" in b:
394
+ merged["words"] = list(a.get("words", [])) + list(b.get("words", []))
395
+ return merged
396
+
397
+ result = []
398
+ i = 0
399
+ n = len(segments)
400
+ while i < n:
401
+ seg = segments[i]
402
+ seg_duration = seg["end"] - seg["start"]
403
+
404
+ if seg_duration >= MIN_DISPLAY:
405
+ result.append(seg)
406
+ i += 1
407
+ continue
408
+
409
+ prev = result[-1] if result else None
410
+ nxt = segments[i + 1] if i + 1 < n else None
411
+
412
+ back_ok, back_text, back_len = (can_merge(prev, seg) if prev is not None else (False, None, None))
413
+ fwd_ok, fwd_text, fwd_len = (can_merge(seg, nxt) if nxt is not None else (False, None, None))
414
+
415
+ if back_ok and (not fwd_ok or back_len <= fwd_len):
416
+ merged = do_merge(prev, seg, back_text)
417
+ result[-1] = merged
418
+ i += 1
419
+ elif fwd_ok:
420
+ merged = do_merge(seg, nxt, fwd_text)
421
+ result.append(merged)
422
+ i += 2
423
+ else:
424
+ result.append(seg)
425
+ i += 1
426
+
427
+ return result
428
+
429
+
430
+ def wrap_text(text: str) -> str:
431
+ """اگه متن بیشتر از WRAP_CHARS بود، به دو خط تقسیم کن.
432
+ اگه متن از قبل یه بلوک دیالوگِ دو-گوینده‌ای (با - و خط جدید) بود، دست‌نخورده برگردون."""
433
+ if text.startswith("- ") and "\n" in text:
434
+ return text
435
+
436
+ if len(text) <= WRAP_CHARS:
437
+ return text
438
+
439
+ words = text.split()
440
+ best_break = -1
441
+ best_diff = float('inf')
442
+ current_len = 0
443
+
444
+ for i, word in enumerate(words):
445
+ current_len += len(word) + (1 if i > 0 else 0)
446
+ diff = abs(current_len - len(text) / 2)
447
+ if diff < best_diff:
448
+ best_diff = diff
449
+ best_break = i + 1
450
+
451
+ if best_break <= 0 or best_break >= len(words):
452
+ return text
453
+
454
+ line1 = " ".join(words[:best_break])
455
+ line2 = " ".join(words[best_break:])
456
+ return f"{line1}\n{line2}"
457
+
458
+
459
+ def build_srt(segments: list) -> str:
460
+ lines = []
461
+ index = 1
462
+ for seg in segments:
463
+ text = seg["text"].strip()
464
+ if not text:
465
+ continue
466
+ start_tc = format_timestamp(seg["start"])
467
+ end_tc = format_timestamp(seg["end"])
468
+ wrapped = wrap_text(text)
469
+ lines.append(f"{index}\n{start_tc} --> {end_tc}\n{wrapped}")
470
+ index += 1
471
+ return "\n\n".join(lines) + "\n"
472
+
473
+
474
+ def process(segments: list) -> str:
475
+ if not segments:
476
+ return ""
477
+
478
+ segments = rebalance_boundaries(segments)
479
+ merged = merge_segments(segments)
480
+ merged = remove_duplicates(merged)
481
+ fixed = fix_overlaps(merged)
482
+
483
+ split = []
484
+ for seg in fixed:
485
+ split.extend(split_long_segment(seg))
486
+
487
+ split = fix_overlaps(split)
488
+ split = merge_short_fragments(split)
489
+ split = fix_overlaps(split)
490
+ final = apply_min_display(split)
491
+
492
+ return build_srt(final)