AliJabbarpour commited on
Commit
1baf2d3
·
verified ·
1 Parent(s): 74d50b4

Upload postprocess.py

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