File size: 28,209 Bytes
1baf2d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
# ─── Post-Processing Engine v10 ────────────────────────────────────────────

MIN_DISPLAY     = 1.5
MAX_DISPLAY     = 7.0   # استاندارد Netflix/Amazon/Disney+: حداکثر ۷ ثانیه برای هر cue
MERGE_MAX_CHARS = 72
MAX_GAP         = 1.5
WRAP_CHARS      = 42    # استاندارد Netflix/Amazon/Disney+: حداکثر ۴۲ کاراکتر در هر خط (نه در کل cue)
MAX_LINES       = 2     # استاندارد: حداکثر ۲ خط در هر cue
CPS_LIMIT       = 17    # حداکثر کاراکتر قابل‌خواندن در ثانیه (استاندارد بین ۱۷ تا ۲۰ کاراکتر بر ثانیه‌ست، ۱۷ رو محافظه‌کارانه انتخاب کردیم)

# کلمه‌های کوچیک و به‌هم‌چسبیده‌ای که نباید تنها و جدا از کلمه‌ی بعدشون
# ته یه segment (چه raw، چه بعد از split) بمونن.
# هم توی split_long_segment استفاده میشه، هم توی rebalance_boundaries.
WEAK_ENDINGS = {
    'in', 'my', 'your', 'his', 'her', 'our', 'their', 'its', 'the', 'a', 'an',
    'of', 'to', 'at', 'on', 'by', 'for', 'with', 'is', 'are', 'was', 'were',
    'and', 'or', 'but', 'so', 'if', 'that', 'this', 'we', 'you', 'he', 'she', 'they',
}


def format_timestamp(seconds: float) -> str:
    ms = round((seconds % 1) * 1000)
    s  = int(seconds) % 60
    m  = int(seconds // 60) % 60
    h  = int(seconds // 3600)
    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"


SENTENCE_ENDERS = {'.', '!', '?'}
LEAD_WORD_BUDGET = 9   # حداکثر تعداد کلمه‌ای که اجازه داریم از ابتدای segment بعدی به عقب بکشیم
LEAD_CHAR_BUDGET = 55  # حداکثر تعداد کاراکتر همون دنباله


def rebalance_boundaries(segments: list) -> list:
    """
    مشکلی که این تابع حل می‌کنه: خودِ Whisper بعضی‌وقتا وسط یه عبارت به‌هم‌چسبیده
    (مثل «in my | opinion» یا «of this | video») مرز segment می‌ذاره — نه سر یه
    مکث طبیعی. چون این دو تیکه معمولاً segment‌های RAW جدا از هم می‌مونن (مثلاً چون
    مجموع مدت‌زمانشون از MAX_DISPLAY بیشتره و merge_segments نمی‌تونه merge‌شون کنه)،
    این مرز اشتباه هیچ‌وقت به merge_segments یا split_long_segment نمی‌رسه که اصلاح بشه.

    این تابع، قبل از هر پردازش دیگه‌ای، مرز بین segmentهای پشت‌سرهم رو از دو جهت چک می‌کنه:

    جهتِ ۱ (رو به جلو): اگه segment اول با یه یا چند کلمه‌ی «ضعیف» و بدون علامت نگارشی
    (in/my/this/your/...) تموم بشه، اون کلمه(ها) به ابتدای segment بعدی منتقل میشن.
    مثال: «...building. In my» + «opinion, ...» → «...building.» + «In my opinion, ...»

    جهتِ ۲ (رو به عقب): اگه segment اول هنوز به یه پایانِ جمله‌ی واقعی (./!/؟) نرسیده باشه،
    و ابتدای segment بعدی یه دنباله‌ی کوتاهه (حداکثر چند کلمه) که زود به یه پایانِ جمله‌ی
    واقعی می‌رسه، اون دنباله به آخر segment اول منتقل میشه — چون یعنی داره همون جمله‌ی
    ناتمومِ قبلی رو کامل می‌کنه، نه اینکه جمله‌ی جدیدی باشه.
    مثال: «...TVs and other» + «electronics. I am curious...» → «...TVs and other electronics.» + «I am curious...»

    هر دو جهت فقط بین segmentهای هم‌گوینده (یا وقتی گوینده مشخص نیست) و با فاصله‌ی کوتاه انجام میشن.
    """
    segments = [dict(s, words=list(s.get("words", []))) for s in segments]

    for i in range(len(segments) - 1):
        cur = segments[i]
        nxt = segments[i + 1]

        cur_words = cur["words"]
        nxt_words = nxt["words"]
        if not cur_words or not nxt_words:
            continue

        # فقط بین segmentهای هم‌گوینده جابه‌جا کن (اگه گوینده مشخص نبود، اجازه بده)
        if cur.get("speaker") is not None and nxt.get("speaker") is not None \
                and cur.get("speaker") != nxt.get("speaker"):
            continue

        # فقط اگه فاصله کوتاهه، یعنی احتمالاً گفتار پیوسته‌ست نه یه مکث واقعی
        gap = nxt_words[0]["start"] - cur_words[-1]["end"]
        if gap > MAX_GAP:
            continue

        moved_any = False

        # جهتِ ۱: کلمه‌ی ضعیفِ آخرِ cur رو به جلو بفرست
        while len(cur_words) > 1:
            last     = cur_words[-1]
            stripped = last["word"].strip()
            bare     = stripped.strip(".,!?;:").lower()
            has_punct = bool(stripped) and stripped[-1] in {'.', '!', '?', ',', ';', ':'}

            if bare in WEAK_ENDINGS and not has_punct:
                nxt_words.insert(0, cur_words.pop())
                moved_any = True
            else:
                break

        # جهتِ ۲: اگه cur هنوز به هیچ علامتِ نگارشیِ طبیعی‌ای (نه حتی ویرگول) نرسیده، دنباله‌ی
        # ابتدای nxt رو — اگه زود به یه ویرگول یا پایانِ جمله‌ی واقعی می‌رسه — به عقب بکش،
        # چون یعنی این دنباله داره همون عبارتِ ناتمومِ قبلی رو کامل می‌کنه، نه اینکه بخشِ
        # جدیدی باشه. فقط اگه بعد از این جابه‌جایی، هنوز بشه با split_long_segment این
        # تیکه‌ی بزرگ‌شده رو درست شکست، انجامش بده. (بدون این چک نهایی، ممکنه یه تیکه‌ی
        # خیلی بلند و بدون هیچ نقطه‌ی شکستنِ باقی‌مونده بسازیم)
        PAUSE_CHARS = SENTENCE_ENDERS | {','}
        cur_last = cur_words[-1]["word"].strip()
        cur_at_pause = bool(cur_last) and cur_last[-1] in PAUSE_CHARS

        if not cur_at_pause and len(nxt_words) > 1:
            lead = []
            lead_chars = 0
            found_end = False
            for w in nxt_words:
                stripped = w["word"].strip()
                if len(lead) >= LEAD_WORD_BUDGET or lead_chars + len(stripped) + 1 > LEAD_CHAR_BUDGET:
                    break
                lead.append(w)
                lead_chars += len(stripped) + 1
                if stripped and stripped[-1] in PAUSE_CHARS:
                    found_end = True
                    break

            if found_end and len(lead) < len(nxt_words):
                trial_words = cur_words + lead
                trial_text  = " ".join(w["word"].strip() for w in trial_words)
                trial_seg   = {"text": trial_text, "start": cur_words[0]["start"],
                               "end": lead[-1]["end"], "words": trial_words}
                trial_pieces = split_long_segment(trial_seg)

                if all(fits_in_two_lines(p["text"]) is not None for p in trial_pieces):
                    for _ in lead:
                        cur_words.append(nxt_words.pop(0))
                    moved_any = True

        # فقط وقتی واقعاً کلمه‌ای جابه‌جا شد، text/start/end رو بازسازی کن —
        # مرزهایی که دست‌نخورده می‌مونن نباید حتی به‌خاطر گرد‌شدگی جزئی تغییر کنن
        if moved_any:
            cur["text"]  = " ".join(w["word"].strip() for w in cur_words)
            cur["end"]   = cur_words[-1]["end"]
            nxt["text"]  = " ".join(w["word"].strip() for w in nxt_words)
            nxt["start"] = nxt_words[0]["start"]

    return segments


def merge_segments(segments: list) -> list:
    if not segments:
        return []

    merged  = []
    current = None

    for seg in segments:
        if current is None:
            current = {
                "text":  seg["text"].strip(),
                "start": seg["start"],
                "end":   seg["end"],
                "words": list(seg.get("words", [])),
                "speaker": seg.get("speaker"),
            }
            continue

        gap               = seg["start"] - current["end"]
        combined_text     = current["text"] + " " + seg["text"].strip()
        combined_duration = seg["end"] - current["start"]
        ends_sentence     = current["text"].rstrip().endswith((".", "!", "?"))
        current_duration  = current["end"] - current["start"]
        is_short          = current_duration < 1.5
        same_speaker      = current.get("speaker") is None or current.get("speaker") == seg.get("speaker")
        too_fast          = (len(current["text"]) / current_duration) > CPS_LIMIT if current_duration > 0 else True
        fits_dialogue     = len(current["text"]) <= WRAP_CHARS and len(seg["text"].strip()) <= WRAP_CHARS
        already_dialogue  = current.get("is_dialogue_block", False)

        should_merge = (
            not ends_sentence
            and combined_duration <= MAX_DISPLAY
            and same_speaker
            and gap <= MAX_GAP
            and (
                is_short
                or len(combined_text) <= MERGE_MAX_CHARS
            )
        )

        should_pair_as_dialogue = (
            not already_dialogue
            and not same_speaker
            and too_fast
            and gap <= MAX_GAP
            and fits_dialogue
        )

        if should_merge:
            current["text"]   = combined_text
            current["end"]    = seg["end"]
            current["words"] += list(seg.get("words", []))
        elif should_pair_as_dialogue:
            current["text"]   = f"- {current['text']}\n- {seg['text'].strip()}"
            current["end"]    = seg["end"]
            current["words"] += list(seg.get("words", []))
            current["is_dialogue_block"] = True
        else:
            merged.append(current)
            current = {
                "text":  seg["text"].strip(),
                "start": seg["start"],
                "end":   seg["end"],
                "words": list(seg.get("words", [])),
                "speaker": seg.get("speaker"),
            }

    if current:
        merged.append(current)

    return merged


def remove_duplicates(segments: list) -> list:
    if not segments:
        return []
    result = [segments[0]]
    for seg in segments[1:]:
        if seg["text"].strip() != result[-1]["text"].strip():
            result.append(seg)
    return result


def fix_overlaps(segments: list) -> list:
    fixed = []
    for seg in segments:
        seg = dict(seg)
        if fixed and seg["start"] < fixed[-1]["end"]:
            seg["start"] = fixed[-1]["end"] + 0.001
        if seg["start"] >= seg["end"]:
            seg["end"] = seg["start"] + MIN_DISPLAY
        fixed.append(seg)
    return fixed


def apply_min_display(segments: list) -> list:
    result = []
    for i, seg in enumerate(segments):
        seg = dict(seg)
        duration = seg["end"] - seg["start"]
        if duration < MIN_DISPLAY:
            if i + 1 < len(segments):
                next_start = segments[i + 1]["start"]
                max_end    = next_start - 0.080
            else:
                max_end = seg["end"] + MAX_DISPLAY
            new_end    = seg["start"] + MIN_DISPLAY
            seg["end"] = min(new_end, max(max_end, seg["end"]))
        result.append(seg)
    return result


def split_long_segment(seg: dict) -> list:
    """
    فقط اگه punctuation نزدیک وسط جمله بود، به دو subtitle جداگانه بشکن.
    اگه نقطه مناسبی نبود، نشکن — wrap_text در build_srt دو خطش می‌کنه.
    بلوک‌های دیالوگِ دو-گوینده‌ای (is_dialogue_block) هیچ‌وقت شکسته نمی‌شن.
    """
    if seg.get("is_dialogue_block"):
        return [seg]

    words = seg.get("words", [])
    text  = seg["text"].strip()

    punct_chars = {'.', '!', '?', ',', ';', ':'}
    sentence_enders = {'.', '!', '?'}
    min_part_chars = 10

    # حتی اگه از سقفِ طول رد نشده باشیم، اگه یه پایانِ جمله‌ی واقعی *وسطِ* متن باشه
    # (نه فقط کلمه‌ی آخر)، یعنی این تیکه در واقع بیشتر از یه جمله‌ست و باید بشکنه —
    # یه cue نباید از رو یه نقطه‌ی واقعی رد بشه، فارغ از اینکه کلاً چقدر کوتاهه
    has_internal_sentence_end = any(
        w["word"].strip() and w["word"].strip()[-1] in sentence_enders
        for w in words[:-1]
    )

    if not has_internal_sentence_end and (len(words) < 4 or fits_in_two_lines(text) is not None):
        return [seg]

    full_text = text
    mid_char = len(full_text) / 2

    best_idx = -1
    best_score = float('-inf')
    char_pos = 0

    for i, w in enumerate(words):
        word_stripped = w["word"].strip()
        this_word_end_char = char_pos + len(word_stripped)

        if word_stripped and word_stripped[-1] in punct_chars and 0 < i < len(words) - 1:
            # چک درست: خودِ کلمه‌ای که این علامت نگارشی روشه، نباید یه کلمه‌ی ضعیف باشه
            bare_word = word_stripped.strip(".,!?;:").lower()
            part1_chars = this_word_end_char
            part2_chars = len(full_text) - this_word_end_char

            if bare_word not in WEAK_ENDINGS and part1_chars >= min_part_chars and part2_chars >= min_part_chars:
                dist_from_mid = abs(this_word_end_char - mid_char)
                proximity_score = 1.0 - (dist_from_mid / len(full_text))
                sentence_end_bonus = 0.5 if word_stripped[-1] in sentence_enders else 0.0
                score = proximity_score + sentence_end_bonus

                if score > best_score:
                    best_score = score
                    best_idx = i + 1

        char_pos = this_word_end_char + 1

    # هیچ نقطه‌ی شکستنِ مبتنی‌بر‌نگارش مناسبی پیدا نشد
    if best_idx == -1:
        # اگه متن همینطوری هم تو ۲ خطِ استاندارد جا میشه، مشکلی نیست، دست‌نخورده برگردون
        if fits_in_two_lines(full_text) is not None:
            return [seg]
        # ولی اگه ذاتاً برای یه cueِ دوخطی خیلی بلنده و هیچ ویرگول/نقطه‌ای هم نداره که
        # ازش کمک بگیریم، باید یه جای معقول (نزدیکِ وسط، رو مرزِ کلمه) بشکنیمش —
        # وگرنه یه cueِ غیرِمنطبق با استاندارد (خطی بیشتر از ۴۲ کاراکتر) تحویل می‌دیم.
        # این دقیقاً همون کاری‌یه که زیرنویس‌سازهای حرفه‌ای هم می‌کنن وقتی جمله‌ی
        # بی‌وقفه و بی‌ویرگولی رو باید بشکنن: رو یه مرزِ طبیعیِ کلمه، نزدیکِ وسط.
        best_idx = None
        best_diff = float('inf')
        semantic_idx = None
        semantic_diff = float('inf')
        LINE_START_OK = WEAK_ENDINGS | {"and", "or", "but", "because", "so", "although",
                                         "which", "who", "that", "when", "while", "if"}
        for i in range(2, len(words) - 1):
            part1 = " ".join(w["word"].strip() for w in words[:i])
            part2 = " ".join(w["word"].strip() for w in words[i:])
            if fits_in_two_lines(part1) is not None and fits_in_two_lines(part2) is not None:
                diff = abs(len(part1) - len(part2))
                if diff < best_diff:
                    best_diff = diff
                    best_idx = i
                # اولویت با نقطه‌ای که رو یه مرزِ نگارشی/معنایی معقول باشه —
                # یعنی کلمه‌ی قبل از شکستن با علامتِ نگارشی تموم بشه، یا کلمه‌ی
                # بعد از شکستن یه ربط‌دهنده باشه — نه وسطِ یه عبارتِ چسبیده
                prev_bare = words[i - 1]["word"].strip()
                next_bare = words[i]["word"].strip().strip(".,!?;:").lower()
                is_semantic = (prev_bare and prev_bare[-1] in punct_chars) or (next_bare in LINE_START_OK)
                if is_semantic and diff < semantic_diff:
                    semantic_diff = diff
                    semantic_idx = i
        if semantic_idx is not None:
            best_idx = semantic_idx
        if best_idx is None:
            return [seg]

    words1 = words[:best_idx]
    words2 = words[best_idx:]

    if len(words1) < 2 or len(words2) < 2:
        return [seg]

    start2 = words2[0]["start"]
    end1   = max(start2 - 0.080, words1[-1]["end"])

    if end1 - words1[0]["start"] < MIN_DISPLAY:
        end1 = words1[0]["start"] + MIN_DISPLAY
        if end1 >= start2:
            end1 = start2 - 0.040

    seg1 = {
        "text":  " ".join(w["word"].strip() for w in words1),
        "start": words1[0]["start"],
        "end":   end1,
        "words": words1,
    }
    seg2 = {
        "text":  " ".join(w["word"].strip() for w in words2),
        "start": start2,
        "end":   words2[-1]["end"],
        "words": words2,
    }

    return split_long_segment(seg1) + split_long_segment(seg2)


def merge_short_fragments(segments: list) -> list:
    """
    بعد از شکستنِ جمله‌های بلند، بعضی‌وقتا یه تیکه‌ی خیلی کوچیک باقی می‌مونه که مدت‌زمان
    نمایشش زیر MIN_DISPLAY (۱.۵ ثانیه) میشه — نه چون واقعاً یه عبارت مستقله، بلکه چون
    نقطه‌ی شکستن (punctuation) دقیقاً همون‌جا بوده. این تابع، به‌صورت یه پاسِ نهایی،
    هر تیکه‌ی زیر ۱.۵ ثانیه رو در صورت امکان با همسایه‌ش (قبلی یا بعدی) یکی می‌کنه —
    فقط وقتی که ادغام از سقف‌های نمایشی (MAX_DISPLAY و طول قابل‌قبول برای دو خط) رد نشه،
    از رو یه پایانِ جمله‌ی واقعی (./!/؟) عبور نکنه، و از همه مهم‌تر: فاصله‌ی زمانیِ واقعی
    بین دو تیکه از MAX_GAP بیشتر نباشه — یعنی اگه یه کلمه یا عبارتِ کوتاه چند ثانیه بعد از
    تیکه‌ی قبلی پخش میشه (یه مکثِ واقعی وسطشونه)، حتی با اینکه کوتاهه، مستقل می‌مونه و به
    تیکه‌ی قبلی نمی‌چسبه.

    نکته: این یه قانونِ کاملاً مکانیکیه (فقط بر اساس مدت‌زمان)، نه یه تشخیص معنایی از
    اینکه عبارت «کامل» هست یا نه — پس ممکنه عبارت‌های کوتاهِ ولی کاملی (مثل «For example,»)
    رو هم با همسایه‌شون ادغام کنه.

    یه نکته‌ی مهمِ دیگه: این تابع همیشه هر دو جهت (چسبیدن به تیکه‌ی قبلی، یا چسبیدن به
    تیکه‌ی بعدی) رو امتحان می‌کنه و هر کدوم که ترکیبِ کوتاه‌تر (جادارتر) بسازه رو انتخاب
    می‌کنه — نه اینکه همیشه فقط به عقب بچسبونه. چون گاهی چسبوندنِ حریصانه به عقب باعث
    میشه یه تیکه‌ی کوتاهِ *بعدی* بی‌جا بمونه (چون هر دو همسایه‌ش پر شدن)، در حالی که اگه
    از اول به جلو می‌چسبید، هیچ‌کدوم بی‌جا نمی‌موندن.
    """
    if not segments:
        return []

    segments = [dict(s) for s in segments]

    def can_merge(a: dict, b: dict) -> tuple:
        """آیا a و b قابل ادغامن؟ اگه بله، (True, متنِ ترکیبی، طولِ متنِ ترکیبی) برمی‌گردونه."""
        if a["text"].rstrip().endswith((".", "!", "?")):
            return False, None, None
        gap = b["start"] - a["end"]
        if gap > MAX_GAP:
            return False, None, None
        combined_duration = b["end"] - a["start"]
        if combined_duration > MAX_DISPLAY:
            return False, None, None
        combined_text = a["text"].rstrip() + " " + b["text"].lstrip()
        if fits_in_two_lines(combined_text) is None:
            return False, None, None
        return True, combined_text, len(combined_text)

    def do_merge(a: dict, b: dict, combined_text: str) -> dict:
        merged = dict(a)
        merged["text"] = combined_text
        merged["end"]  = b["end"]
        if "words" in a or "words" in b:
            merged["words"] = list(a.get("words", [])) + list(b.get("words", []))
        return merged

    result = []
    i = 0
    n = len(segments)
    while i < n:
        seg = segments[i]
        seg_duration = seg["end"] - seg["start"]

        if seg_duration >= MIN_DISPLAY:
            result.append(seg)
            i += 1
            continue

        prev = result[-1] if result else None
        nxt  = segments[i + 1] if i + 1 < n else None

        back_ok, back_text, back_len = (can_merge(prev, seg) if prev is not None else (False, None, None))
        fwd_ok,  fwd_text,  fwd_len  = (can_merge(seg, nxt) if nxt is not None else (False, None, None))

        if back_ok and (not fwd_ok or back_len <= fwd_len):
            merged = do_merge(prev, seg, back_text)
            result[-1] = merged
            i += 1
        elif fwd_ok:
            merged = do_merge(seg, nxt, fwd_text)
            result.append(merged)
            i += 2
        else:
            result.append(seg)
            i += 1

    return result


def fits_in_two_lines(text: str) -> str | None:
    """
    طبقِ استانداردِ واقعیِ زیرنویس (Netflix/Amazon/Disney+: حداکثر ۴۲ کاراکتر در هر خط،
    حداکثر ۲ خط در هر cue)، سعی می‌کنه متن رو به ۱ یا ۲ خط بشکنه طوری که هیچ‌کدوم از
    خط‌ها از WRAP_CHARS بیشتر نشه.

    بینِ نقاطِ معتبر (هر دو خط ≤۴۲)، صرفاً «متعادل‌ترین از نظرِ طول» رو انتخاب نمی‌کنه —
    اول دنبالِ نقطه‌ای می‌گرده که از نظرِ نگارشی/معنایی هم معقول باشه (بعدِ یه ویرگول یا
    علامتِ نگارشی، یا لااقل قبل از یه کلمه‌ی ربط‌دهنده مثل and/or/because)، تا یه عبارتِ
    چسبیده (مثل «certain locations») وسط بریده نشه. فقط وقتی هیچ‌کدومِ نقاطِ معتبر چنین
    ویژگی‌ای نداشتن، سراغِ متعادل‌ترینِ صرفاً از نظرِ طول می‌ره.

    اگه ممکن باشه، متنِ شکسته‌شده (یا خودِ متن، اگه از اول یه‌خطی بود) رو برمی‌گردونه.
    اگه هیچ نقطه‌ی شکستنی وجود نداشته باشه که هر دو خط رو زیرِ ۴۲ نگه داره، None برمی‌گردونه —
    یعنی این متن ذاتاً برای یه cueِ دوخطی خیلی بلنده و باید به چند subtitleِ جدا تقسیم بشه؛
    این تشخیص با یه عددِ اختیاریِ حدسی (مثل «۹۴ کاراکتر») جایگزین نمیشه.
    """
    if len(text) <= WRAP_CHARS:
        return text

    words = text.split()

    # کلمه‌هایی که اگه یه خط باهاشون شروع بشه، یعنی نقطه‌ی شکستنِ قبلشون از نظرِ
    # نگارشی/معنایی معقوله (چه به‌خاطرِ ویرگول/نقطه‌ی قبلش، چه چون خودش یه ربط‌دهنده‌ست)
    LINE_START_OK = WEAK_ENDINGS | {"and", "or", "but", "because", "so", "although",
                                     "which", "who", "that", "when", "while", "if"}

    valid = []  # (i, line1, line2, diff)
    for i in range(1, len(words)):
        line1 = " ".join(words[:i])
        line2 = " ".join(words[i:])
        if len(line1) <= WRAP_CHARS and len(line2) <= WRAP_CHARS:
            diff = abs(len(line1) - len(line2))
            valid.append((i, line1, line2, diff))

    if not valid:
        return None

    def is_semantically_ok(i: int, line1: str) -> bool:
        # یا خودِ کلمه‌ی قبل از نقطه‌ی شکستن با علامتِ نگارشی تموم میشه (ویرگول/نقطه/...)،
        # یا خطِ بعدی با یه کلمه‌ی ربط‌دهنده/ضعیف شروع میشه — یعنی این خط یه عبارتِ
        # چسبیده‌ی وسط‌بریده نیست
        prev_word = words[i - 1].strip()
        if prev_word and prev_word[-1] in {',', '.', '!', '?', ';', ':'}:
            return True
        next_word_bare = words[i].strip(".,!?;:").lower()
        return next_word_bare in LINE_START_OK

    semantically_ok = [v for v in valid if is_semantically_ok(v[0], v[1])]
    pool = semantically_ok if semantically_ok else valid

    best = min(pool, key=lambda v: v[3])
    return f"{best[1]}\n{best[2]}"


def wrap_text(text: str) -> str:
    """متن رو طبقِ قانونِ واقعیِ ۴۲کاراکتری به ۱ یا ۲ خط می‌شکنه.
    اگه متن از قبل یه بلوک دیالوگِ دو-گوینده‌ای (با - و خط جدید) بود، دست‌نخورده برگردون.
    اگه (که نباید پیش بیاد، چون split_long_segment از قبل تضمین کرده) هیچ شکستنِ معتبری
    پیدا نشد، به‌جای گم‌کردنِ متن، همون شکستنِ نزدیک‌به‌وسطِ قدیمی رو به‌عنوانِ آخرین راه‌حل می‌زنیم."""
    if text.startswith("- ") and "\n" in text:
        return text

    fitted = fits_in_two_lines(text)
    if fitted is not None:
        return fitted

    # fallback نادر: وقتی هیچ شکستنی زیرِ ۴۲ پیدا نشه (نباید معمولاً پیش بیاد)
    words = text.split()
    best_break = -1
    best_diff = float('inf')
    current_len = 0
    for i, word in enumerate(words):
        current_len += len(word) + (1 if i > 0 else 0)
        diff = abs(current_len - len(text) / 2)
        if diff < best_diff:
            best_diff = diff
            best_break = i + 1
    if best_break <= 0 or best_break >= len(words):
        return text
    line1 = " ".join(words[:best_break])
    line2 = " ".join(words[best_break:])
    return f"{line1}\n{line2}"


def build_srt(segments: list) -> str:
    lines = []
    index = 1
    for seg in segments:
        text = seg["text"].strip()
        if not text:
            continue
        start_tc = format_timestamp(seg["start"])
        end_tc   = format_timestamp(seg["end"])
        wrapped  = wrap_text(text)
        lines.append(f"{index}\n{start_tc} --> {end_tc}\n{wrapped}")
        index += 1
    return "\n\n".join(lines) + "\n"


def process(segments: list) -> str:
    if not segments:
        return ""

    segments = rebalance_boundaries(segments)
    merged = merge_segments(segments)
    merged = remove_duplicates(merged)
    fixed  = fix_overlaps(merged)

    split = []
    for seg in fixed:
        split.extend(split_long_segment(seg))

    split = fix_overlaps(split)
    split = merge_short_fragments(split)
    split = fix_overlaps(split)
    final = apply_min_display(split)

    return build_srt(final)