AliJabbarpour commited on
Commit
9cf43ab
·
verified ·
1 Parent(s): 4e92ebd

Delete pipeline/postprocess.py

Browse files
Files changed (1) hide show
  1. pipeline/postprocess.py +0 -435
pipeline/postprocess.py DELETED
@@ -1,435 +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 = 60 # threshold برای split به دو subtitle جداگانه
8
- WRAP_CHARS = 42 # استاندارد Netflix برای wrap به دو خط
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 هنوز به یه پایانِ جمله‌ی واقعی نرسیده، دنباله‌ی کوتاهِ ابتدای nxt رو
93
- # (اگه زود به یه پایانِ جمله‌ی واقعی می‌رسه) به عقب بکش
94
- cur_last = cur_words[-1]["word"].strip()
95
- cur_ends_sentence = bool(cur_last) and cur_last[-1] in SENTENCE_ENDERS
96
-
97
- if not cur_ends_sentence and len(nxt_words) > 1:
98
- lead = []
99
- lead_chars = 0
100
- found_end = False
101
- for w in nxt_words:
102
- if len(lead) >= LEAD_WORD_BUDGET or lead_chars >= LEAD_CHAR_BUDGET:
103
- break
104
- stripped = w["word"].strip()
105
- lead.append(w)
106
- lead_chars += len(stripped) + 1
107
- if stripped and stripped[-1] in SENTENCE_ENDERS:
108
- found_end = True
109
- break
110
-
111
- # فقط وقتی که به یه پایانِ جمله‌ی واقعی رسیدیم (نه چون بودجه‌مون تموم شد)،
112
- # و حداقل یه کلمه توی nxt باقی می‌مونه، جابه‌جایی رو انجام بده
113
- if found_end and len(lead) < len(nxt_words):
114
- for _ in lead:
115
- cur_words.append(nxt_words.pop(0))
116
- moved_any = True
117
-
118
- # فقط وقتی واقعاً کلمه‌ای جابه‌جا شد، text/start/end رو بازسازی کن —
119
- # مرزهایی که دست‌نخورده می‌مونن نباید حتی به‌خاطر گرد‌شدگی جزئی تغییر کنن
120
- if moved_any:
121
- cur["text"] = " ".join(w["word"].strip() for w in cur_words)
122
- cur["end"] = cur_words[-1]["end"]
123
- nxt["text"] = " ".join(w["word"].strip() for w in nxt_words)
124
- nxt["start"] = nxt_words[0]["start"]
125
-
126
- return segments
127
-
128
-
129
- def merge_segments(segments: list) -> list:
130
- if not segments:
131
- return []
132
-
133
- merged = []
134
- current = None
135
-
136
- for seg in segments:
137
- if current is None:
138
- current = {
139
- "text": seg["text"].strip(),
140
- "start": seg["start"],
141
- "end": seg["end"],
142
- "words": list(seg.get("words", [])),
143
- "speaker": seg.get("speaker"),
144
- }
145
- continue
146
-
147
- gap = seg["start"] - current["end"]
148
- combined_text = current["text"] + " " + seg["text"].strip()
149
- combined_duration = seg["end"] - current["start"]
150
- ends_sentence = current["text"].rstrip().endswith((".", "!", "?"))
151
- current_duration = current["end"] - current["start"]
152
- is_short = current_duration < 1.5
153
- same_speaker = current.get("speaker") is None or current.get("speaker") == seg.get("speaker")
154
- too_fast = (len(current["text"]) / current_duration) > CPS_LIMIT if current_duration > 0 else True
155
- fits_dialogue = len(current["text"]) <= WRAP_CHARS and len(seg["text"].strip()) <= WRAP_CHARS
156
- already_dialogue = current.get("is_dialogue_block", False)
157
-
158
- should_merge = (
159
- not ends_sentence
160
- and combined_duration <= MAX_DISPLAY
161
- and same_speaker
162
- and gap <= MAX_GAP
163
- and (
164
- is_short
165
- or len(combined_text) <= MERGE_MAX_CHARS
166
- )
167
- )
168
-
169
- should_pair_as_dialogue = (
170
- not already_dialogue
171
- and not same_speaker
172
- and too_fast
173
- and gap <= MAX_GAP
174
- and fits_dialogue
175
- )
176
-
177
- if should_merge:
178
- current["text"] = combined_text
179
- current["end"] = seg["end"]
180
- current["words"] += list(seg.get("words", []))
181
- elif should_pair_as_dialogue:
182
- current["text"] = f"- {current['text']}\n- {seg['text'].strip()}"
183
- current["end"] = seg["end"]
184
- current["words"] += list(seg.get("words", []))
185
- current["is_dialogue_block"] = True
186
- else:
187
- merged.append(current)
188
- current = {
189
- "text": seg["text"].strip(),
190
- "start": seg["start"],
191
- "end": seg["end"],
192
- "words": list(seg.get("words", [])),
193
- "speaker": seg.get("speaker"),
194
- }
195
-
196
- if current:
197
- merged.append(current)
198
-
199
- return merged
200
-
201
-
202
- def remove_duplicates(segments: list) -> list:
203
- if not segments:
204
- return []
205
- result = [segments[0]]
206
- for seg in segments[1:]:
207
- if seg["text"].strip() != result[-1]["text"].strip():
208
- result.append(seg)
209
- return result
210
-
211
-
212
- def fix_overlaps(segments: list) -> list:
213
- fixed = []
214
- for seg in segments:
215
- seg = dict(seg)
216
- if fixed and seg["start"] < fixed[-1]["end"]:
217
- seg["start"] = fixed[-1]["end"] + 0.001
218
- if seg["start"] >= seg["end"]:
219
- seg["end"] = seg["start"] + MIN_DISPLAY
220
- fixed.append(seg)
221
- return fixed
222
-
223
-
224
- def apply_min_display(segments: list) -> list:
225
- result = []
226
- for i, seg in enumerate(segments):
227
- seg = dict(seg)
228
- duration = seg["end"] - seg["start"]
229
- if duration < MIN_DISPLAY:
230
- if i + 1 < len(segments):
231
- next_start = segments[i + 1]["start"]
232
- max_end = next_start - 0.080
233
- else:
234
- max_end = seg["end"] + MAX_DISPLAY
235
- new_end = seg["start"] + MIN_DISPLAY
236
- seg["end"] = min(new_end, max(max_end, seg["end"]))
237
- result.append(seg)
238
- return result
239
-
240
-
241
- def split_long_segment(seg: dict) -> list:
242
- """
243
- فقط اگه punctuation نزدیک وسط جمله بود، به دو subtitle جداگانه بشکن.
244
- اگه نقطه مناسبی نبود، نشکن — wrap_text در build_srt دو خطش می‌کنه.
245
- بلوک‌های دیالوگِ دو-گوینده‌ای (is_dialogue_block) هیچ‌وقت شکسته نمی‌شن.
246
- """
247
- if seg.get("is_dialogue_block"):
248
- return [seg]
249
-
250
- words = seg.get("words", [])
251
- text = seg["text"].strip()
252
-
253
- if len(text) <= MAX_LINE_CHARS or len(words) < 4:
254
- return [seg]
255
-
256
- punct_chars = {'.', '!', '?', ',', ';', ':'}
257
- sentence_enders = {'.', '!', '?'}
258
- min_part_chars = 10
259
-
260
- full_text = text
261
- mid_char = len(full_text) / 2
262
-
263
- best_idx = -1
264
- best_score = float('-inf')
265
- char_pos = 0
266
-
267
- for i, w in enumerate(words):
268
- word_stripped = w["word"].strip()
269
- this_word_end_char = char_pos + len(word_stripped)
270
-
271
- if word_stripped and word_stripped[-1] in punct_chars and 0 < i < len(words) - 1:
272
- # چک درست: خودِ کلمه‌ای که این علامت نگارشی روشه، نباید یه کلمه‌ی ضعیف باشه
273
- bare_word = word_stripped.strip(".,!?;:").lower()
274
- part1_chars = this_word_end_char
275
- part2_chars = len(full_text) - this_word_end_char
276
-
277
- if bare_word not in WEAK_ENDINGS and part1_chars >= min_part_chars and part2_chars >= min_part_chars:
278
- dist_from_mid = abs(this_word_end_char - mid_char)
279
- proximity_score = 1.0 - (dist_from_mid / len(full_text))
280
- sentence_end_bonus = 0.5 if word_stripped[-1] in sentence_enders else 0.0
281
- score = proximity_score + sentence_end_bonus
282
-
283
- if score > best_score:
284
- best_score = score
285
- best_idx = i + 1
286
-
287
- char_pos = this_word_end_char + 1
288
-
289
- # هیچ نقطه‌ی شکستن مناسبی پیدا نشد → نشکن، wrap_text کافیه
290
- if best_idx == -1:
291
- return [seg]
292
-
293
- words1 = words[:best_idx]
294
- words2 = words[best_idx:]
295
-
296
- if len(words1) < 2 or len(words2) < 2:
297
- return [seg]
298
-
299
- start2 = words2[0]["start"]
300
- end1 = max(start2 - 0.080, words1[-1]["end"])
301
-
302
- if end1 - words1[0]["start"] < MIN_DISPLAY:
303
- end1 = words1[0]["start"] + MIN_DISPLAY
304
- if end1 >= start2:
305
- end1 = start2 - 0.040
306
-
307
- seg1 = {
308
- "text": " ".join(w["word"].strip() for w in words1),
309
- "start": words1[0]["start"],
310
- "end": end1,
311
- "words": words1,
312
- }
313
- seg2 = {
314
- "text": " ".join(w["word"].strip() for w in words2),
315
- "start": start2,
316
- "end": words2[-1]["end"],
317
- "words": words2,
318
- }
319
-
320
- return split_long_segment(seg1) + split_long_segment(seg2)
321
-
322
-
323
- def merge_short_fragments(segments: list) -> list:
324
- """
325
- بعد از شکستنِ جمله‌های بلند، بعضی‌وقتا یه تیکه‌ی خیلی کوچیک باقی می‌مونه که مدت‌زمان
326
- نمایشش زیر MIN_DISPLAY (۱.۵ ثانیه) میشه — نه چون واقعاً یه عبارت مستقله، بلکه چون
327
- نقطه‌ی شکستن (punctuation) دقیقاً همون‌جا بوده. این تابع، به‌صورت یه پاسِ نهایی،
328
- هر تیکه‌ی زیر ۱.۵ ثانیه رو در صورت امکان با همسایه‌ش (قبلی یا بعدی) یکی می‌کنه —
329
- فقط وقتی که ادغام از سقف‌های نمایشی (MAX_DISPLAY و طول قابل‌قبول برای دو خط) رد نشه،
330
- از رو یه پایانِ جمله‌ی واقعی (./!/؟) عبور نکنه، و از همه مهم‌تر: فاصله‌ی زمانیِ واقعی
331
- بین دو تیکه از MAX_GAP بیشتر نباشه — یعنی اگه یه کلمه یا عبارتِ کوتاه چند ثانیه بعد از
332
- تیکه‌ی قبلی پخش میشه (یه مکثِ واقعی وسطشونه)، حتی با اینکه کوتاهه، مستقل می‌مونه و به
333
- تیکه‌ی قبلی نمی‌چسبه.
334
-
335
- نکته: این یه قانونِ کاملاً مکانیکیه (فقط بر اساس مدت‌زمان)، نه یه تشخیص معنایی از
336
- اینکه عبارت «کامل» هست یا نه — پس ممکنه عبارت‌های کوتاهِ ولی کاملی (مثل «For example,»)
337
- رو هم با همسایه‌شون ادغام کنه.
338
- """
339
- if not segments:
340
- return []
341
-
342
- MAX_MERGED_CHARS = WRAP_CHARS * 2 + 10 # فضای قابل‌قبول برای یه cue دو‌خطی
343
-
344
- segments = [dict(s) for s in segments]
345
- result = [segments[0]]
346
-
347
- for seg in segments[1:]:
348
- prev = result[-1]
349
- prev_duration = prev["end"] - prev["start"]
350
- seg_duration = seg["end"] - seg["start"]
351
-
352
- prev_ends_sentence = prev["text"].rstrip().endswith((".", "!", "?"))
353
- gap = seg["start"] - prev["end"]
354
-
355
- should_merge = False
356
- if not prev_ends_sentence and gap <= MAX_GAP and (seg_duration < MIN_DISPLAY or prev_duration < MIN_DISPLAY):
357
- combined_duration = seg["end"] - prev["start"]
358
- combined_text = prev["text"].rstrip() + " " + seg["text"].lstrip()
359
- if combined_duration <= MAX_DISPLAY and len(combined_text) <= MAX_MERGED_CHARS:
360
- should_merge = True
361
-
362
- if should_merge:
363
- prev["text"] = prev["text"].rstrip() + " " + seg["text"].lstrip()
364
- prev["end"] = seg["end"]
365
- if "words" in prev or "words" in seg:
366
- prev["words"] = list(prev.get("words", [])) + list(seg.get("words", []))
367
- else:
368
- result.append(dict(seg))
369
-
370
- return result
371
-
372
-
373
- def wrap_text(text: str) -> str:
374
- """اگه متن بیشتر از WRAP_CHARS بود، به دو خط تقسیم کن.
375
- اگه متن از قبل یه بلوک دیالوگِ دو-گوینده‌ای (با - و خط جدید) بود، دست‌نخورده برگردون."""
376
- if text.startswith("- ") and "\n" in text:
377
- return text
378
-
379
- if len(text) <= WRAP_CHARS:
380
- return text
381
-
382
- words = text.split()
383
- best_break = -1
384
- best_diff = float('inf')
385
- current_len = 0
386
-
387
- for i, word in enumerate(words):
388
- current_len += len(word) + (1 if i > 0 else 0)
389
- diff = abs(current_len - len(text) / 2)
390
- if diff < best_diff:
391
- best_diff = diff
392
- best_break = i + 1
393
-
394
- if best_break <= 0 or best_break >= len(words):
395
- return text
396
-
397
- line1 = " ".join(words[:best_break])
398
- line2 = " ".join(words[best_break:])
399
- return f"{line1}\n{line2}"
400
-
401
-
402
- def build_srt(segments: list) -> str:
403
- lines = []
404
- index = 1
405
- for seg in segments:
406
- text = seg["text"].strip()
407
- if not text:
408
- continue
409
- start_tc = format_timestamp(seg["start"])
410
- end_tc = format_timestamp(seg["end"])
411
- wrapped = wrap_text(text)
412
- lines.append(f"{index}\n{start_tc} --> {end_tc}\n{wrapped}")
413
- index += 1
414
- return "\n\n".join(lines) + "\n"
415
-
416
-
417
- def process(segments: list) -> str:
418
- if not segments:
419
- return ""
420
-
421
- segments = rebalance_boundaries(segments)
422
- merged = merge_segments(segments)
423
- merged = remove_duplicates(merged)
424
- fixed = fix_overlaps(merged)
425
-
426
- split = []
427
- for seg in fixed:
428
- split.extend(split_long_segment(seg))
429
-
430
- split = fix_overlaps(split)
431
- split = merge_short_fragments(split)
432
- split = fix_overlaps(split)
433
- final = apply_min_display(split)
434
-
435
- return build_srt(final)