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

Delete pipeline/postprocess.py

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