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

Delete pipeline/postprocess.py

Browse files
Files changed (1) hide show
  1. pipeline/postprocess.py +0 -576
pipeline/postprocess.py DELETED
@@ -1,576 +0,0 @@
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)