pycaption 2.2.24__tar.gz → 2.2.25__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pycaption-2.2.24/pycaption.egg-info → pycaption-2.2.25}/PKG-INFO +1 -1
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/__init__.py +100 -22
- {pycaption-2.2.24 → pycaption-2.2.25/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption.egg-info/SOURCES.txt +1 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/setup.py +1 -1
- pycaption-2.2.25/tests/test_scc_writer.py +255 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/LICENSE +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/MANIFEST.in +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/README.rst +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/__init__.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/base.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/dfxp/base.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/exceptions.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/geometry.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/microdvd.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/sami.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/constants.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/specialized_collections.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/translator.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/srt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/transcript.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/utils.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/webvtt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/setup.cfg +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/__init__.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/conftest.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/sami.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/scc.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/srt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/mixins.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_base.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_dfxp.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_functions.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_geometry.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_microdvd.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_sami.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_scc.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_scc_translator.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_srt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_webvtt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_webvtt_conversion.py +0 -0
|
@@ -335,8 +335,7 @@ class SCCReader(BaseReader):
|
|
|
335
335
|
# Look ahead for the next command, skipping the duplicate
|
|
336
336
|
# that SCC uses for error-correction (same word repeated).
|
|
337
337
|
next_idx = idx + 1
|
|
338
|
-
if
|
|
339
|
-
and word_list[next_idx].strip() == word):
|
|
338
|
+
if next_idx < len(word_list) and word_list[next_idx].strip() == word:
|
|
340
339
|
next_idx += 1
|
|
341
340
|
next_command = (
|
|
342
341
|
word_list[next_idx] if next_idx < len(word_list) else None
|
|
@@ -496,10 +495,11 @@ class SCCReader(BaseReader):
|
|
|
496
495
|
edm_time = self.time_translator.get_time()
|
|
497
496
|
if self.pop_ons_queue:
|
|
498
497
|
self._pop_on(end=edm_time)
|
|
499
|
-
if
|
|
500
|
-
|
|
501
|
-
self.
|
|
502
|
-
|
|
498
|
+
if (
|
|
499
|
+
self.buffer_dict.active_key in ("paint", "roll")
|
|
500
|
+
and not self.buffer.is_empty()
|
|
501
|
+
):
|
|
502
|
+
self.caption_stash.create_and_store(self.buffer, self.time, edm_time)
|
|
503
503
|
self.buffer = self.node_creator_factory.new_creator()
|
|
504
504
|
self.node_creator_factory.position_tracker.reset_for_new_caption()
|
|
505
505
|
self.time = edm_time
|
|
@@ -563,9 +563,17 @@ class SCCReader(BaseReader):
|
|
|
563
563
|
self.caption_stash.create_and_store(pop_on_cue.buffer, pop_on_cue.start, end)
|
|
564
564
|
|
|
565
565
|
|
|
566
|
+
SCC_TOKENS_PER_CAPTION_MAX = 80
|
|
567
|
+
|
|
568
|
+
_SCC_PREFIX = ["94ae", "94ae", "9420", "9420"]
|
|
569
|
+
_SCC_SUFFIX = ["942c", "942c", "942f", "942f"]
|
|
570
|
+
_SCC_OVERHEAD = len(_SCC_PREFIX) + len(_SCC_SUFFIX)
|
|
571
|
+
|
|
572
|
+
|
|
566
573
|
class SCCWriter(BaseWriter):
|
|
567
|
-
def __init__(self, *args, **kw):
|
|
574
|
+
def __init__(self, *args, drop_frame=False, **kw):
|
|
568
575
|
super().__init__(*args, **kw)
|
|
576
|
+
self.drop_frame = drop_frame
|
|
569
577
|
|
|
570
578
|
def write(self, caption_set):
|
|
571
579
|
output = HEADER + "\n\n"
|
|
@@ -589,24 +597,64 @@ class SCCWriter(BaseWriter):
|
|
|
589
597
|
# Advance start times so as to have time to write to the pop-on
|
|
590
598
|
# buffer; possibly remove the previous clear-screen command
|
|
591
599
|
for index, (code, start, end) in enumerate(codes):
|
|
592
|
-
code_words = len(code)
|
|
600
|
+
code_words = len(code.split()) + _SCC_OVERHEAD
|
|
593
601
|
code_time_microseconds = code_words * MICROSECONDS_PER_CODEWORD
|
|
594
602
|
code_start = start - code_time_microseconds
|
|
595
|
-
if
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
603
|
+
if code_start < 0:
|
|
604
|
+
code_start = 0
|
|
605
|
+
if index == 0:
|
|
606
|
+
codes[index] = (code, code_start, end)
|
|
607
|
+
continue
|
|
608
|
+
previous_code, previous_start, previous_end = codes[index - 1]
|
|
609
|
+
if code_start <= previous_start + MICROSECONDS_PER_CODEWORD:
|
|
610
|
+
prev_words = len(previous_code.split()) + _SCC_OVERHEAD
|
|
611
|
+
code_start = max(
|
|
612
|
+
code_start,
|
|
613
|
+
previous_start + prev_words * MICROSECONDS_PER_CODEWORD,
|
|
614
|
+
)
|
|
615
|
+
codes[index] = (code, code_start, end)
|
|
616
|
+
codes[index - 1] = (previous_code, previous_start, None)
|
|
617
|
+
else:
|
|
618
|
+
if (
|
|
619
|
+
previous_end is not None
|
|
620
|
+
and previous_end + 3 * MICROSECONDS_PER_CODEWORD >= code_start
|
|
621
|
+
):
|
|
600
622
|
codes[index - 1] = (previous_code, previous_start, None)
|
|
601
|
-
|
|
623
|
+
codes[index] = (code, code_start, end)
|
|
602
624
|
|
|
603
625
|
# PASS 3:
|
|
604
|
-
#
|
|
626
|
+
# Deduplicate timestamps — ensure monotonically increasing frame values
|
|
627
|
+
last_emitted_frame = -1
|
|
628
|
+
for index, (code, start, end) in enumerate(codes):
|
|
629
|
+
cur_frame = self._microseconds_to_frame(start)
|
|
630
|
+
if cur_frame <= last_emitted_frame:
|
|
631
|
+
while self._microseconds_to_frame(start) <= last_emitted_frame:
|
|
632
|
+
start += MICROSECONDS_PER_CODEWORD
|
|
633
|
+
codes[index] = (code, start, end)
|
|
634
|
+
last_emitted_frame = self._microseconds_to_frame(start)
|
|
635
|
+
|
|
636
|
+
# PASS 4:
|
|
637
|
+
# Write captions, splitting if > SCC_TOKENS_PER_CAPTION_MAX tokens.
|
|
638
|
+
max_payload = SCC_TOKENS_PER_CAPTION_MAX - _SCC_OVERHEAD
|
|
605
639
|
for code, start, end in codes:
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
640
|
+
code_tokens = code.split()
|
|
641
|
+
if len(code_tokens) + _SCC_OVERHEAD <= SCC_TOKENS_PER_CAPTION_MAX:
|
|
642
|
+
output += f"{self._format_timestamp(start)}\t"
|
|
643
|
+
output += "94ae 94ae 9420 9420 "
|
|
644
|
+
output += code
|
|
645
|
+
output += "942c 942c 942f 942f\n\n"
|
|
646
|
+
else:
|
|
647
|
+
offset = 0
|
|
648
|
+
while offset < len(code_tokens):
|
|
649
|
+
chunk = code_tokens[offset : offset + max_payload]
|
|
650
|
+
line = _SCC_PREFIX + chunk + _SCC_SUFFIX
|
|
651
|
+
output += (
|
|
652
|
+
f"{self._format_timestamp(start)}\t" + " ".join(line) + "\n\n"
|
|
653
|
+
)
|
|
654
|
+
offset += max_payload
|
|
655
|
+
if offset < len(code_tokens):
|
|
656
|
+
start += MICROSECONDS_PER_CODEWORD
|
|
657
|
+
|
|
610
658
|
if end is not None:
|
|
611
659
|
output += f"{self._format_timestamp(end)}\t942c 942c\n\n"
|
|
612
660
|
|
|
@@ -668,10 +716,20 @@ class SCCWriter(BaseWriter):
|
|
|
668
716
|
code = self._maybe_align(code)
|
|
669
717
|
return code
|
|
670
718
|
|
|
719
|
+
def _format_timestamp(self, microseconds):
|
|
720
|
+
if self.drop_frame:
|
|
721
|
+
return self._format_timestamp_df(microseconds)
|
|
722
|
+
return self._format_timestamp_ndf(microseconds)
|
|
723
|
+
|
|
724
|
+
def _microseconds_to_frame(self, microseconds):
|
|
725
|
+
if self.drop_frame:
|
|
726
|
+
return math.floor(microseconds * 30 / 1_000_000 * 1000 / 1001 + 1e-9)
|
|
727
|
+
seconds_float = microseconds / 1_000_000.0 * 1000.0 / 1001.0
|
|
728
|
+
return math.floor(seconds_float * 30)
|
|
729
|
+
|
|
671
730
|
@staticmethod
|
|
672
|
-
def
|
|
673
|
-
seconds_float = microseconds /
|
|
674
|
-
# Convert to non-drop-frame timecode
|
|
731
|
+
def _format_timestamp_ndf(microseconds):
|
|
732
|
+
seconds_float = microseconds / 1_000_000.0
|
|
675
733
|
seconds_float *= 1000.0 / 1001.0
|
|
676
734
|
hours = math.floor(seconds_float / 3600)
|
|
677
735
|
seconds_float -= hours * 3600
|
|
@@ -682,6 +740,26 @@ class SCCWriter(BaseWriter):
|
|
|
682
740
|
frames = math.floor(seconds_float * 30)
|
|
683
741
|
return f"{hours:02}:{minutes:02}:{seconds:02}:{frames:02}"
|
|
684
742
|
|
|
743
|
+
@staticmethod
|
|
744
|
+
def _format_timestamp_df(microseconds):
|
|
745
|
+
total_frames = math.floor(microseconds * 30 / 1_000_000 * 1000 / 1001 + 1e-9)
|
|
746
|
+
fps = 30
|
|
747
|
+
frames_per_10min = 17982 # 10*60*30 - 2*9
|
|
748
|
+
d = total_frames // frames_per_10min
|
|
749
|
+
m = total_frames % frames_per_10min
|
|
750
|
+
if m < 2:
|
|
751
|
+
tc_frames = total_frames + 18 * d
|
|
752
|
+
else:
|
|
753
|
+
tc_frames = total_frames + 18 * d + 2 * ((m - 2) // 1798)
|
|
754
|
+
|
|
755
|
+
hours = tc_frames // (fps * 60 * 60)
|
|
756
|
+
rem = tc_frames % (fps * 60 * 60)
|
|
757
|
+
minutes = rem // (fps * 60)
|
|
758
|
+
rem = rem % (fps * 60)
|
|
759
|
+
seconds = rem // fps
|
|
760
|
+
frames = rem % fps
|
|
761
|
+
return f"{hours:02}:{minutes:02}:{seconds:02};{frames:02}"
|
|
762
|
+
|
|
685
763
|
|
|
686
764
|
class _SccTimeTranslator:
|
|
687
765
|
"""Converts SCC time to microseconds, keeping track of frames passed"""
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from pycaption import SCCReader, SCCWriter, SRTReader, WebVTTReader
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestSCCWriterTimestampFormatting:
|
|
8
|
+
def test_ndf_timestamp_uses_colons(self):
|
|
9
|
+
assert SCCWriter._format_timestamp_ndf(0) == "00:00:00:00"
|
|
10
|
+
assert ";" not in SCCWriter._format_timestamp_ndf(1_000_000)
|
|
11
|
+
|
|
12
|
+
def test_ndf_known_values(self):
|
|
13
|
+
assert SCCWriter._format_timestamp_ndf(0) == "00:00:00:00"
|
|
14
|
+
# 1 second of real time at 29.97fps NDF
|
|
15
|
+
# 1_000_000 us * 1000/1001 = 999.000999 coded seconds → 29 frames
|
|
16
|
+
assert SCCWriter._format_timestamp_ndf(1_000_000) == "00:00:00:29"
|
|
17
|
+
# 60 seconds real → 59.94 coded seconds → 59s + 28 frames
|
|
18
|
+
assert SCCWriter._format_timestamp_ndf(60_000_000) == "00:00:59:28"
|
|
19
|
+
|
|
20
|
+
def test_df_timestamp_uses_semicolons(self):
|
|
21
|
+
result = SCCWriter._format_timestamp_df(1_000_000)
|
|
22
|
+
assert ";" in result
|
|
23
|
+
|
|
24
|
+
def test_df_known_values(self):
|
|
25
|
+
assert SCCWriter._format_timestamp_df(0) == "00:00:00;00"
|
|
26
|
+
# 10 minutes of real time should map to exactly 10:00;00 in DF
|
|
27
|
+
ten_minutes_us = 10 * 60 * 1_000_000
|
|
28
|
+
assert SCCWriter._format_timestamp_df(ten_minutes_us) == "00:10:00;00"
|
|
29
|
+
|
|
30
|
+
def test_df_frame_skip_at_minute_boundary(self):
|
|
31
|
+
# Real frame 1800 is the first frame of minute 1 in DF.
|
|
32
|
+
# Minute 0 has 1800 frames (0..1799), no drops.
|
|
33
|
+
# Minute 1 starts at real frame 1800, displayed as 00:01:00;02
|
|
34
|
+
# (frames ;00 and ;01 are dropped at non-10th minutes).
|
|
35
|
+
us_for_frame_1800 = round(1800 * 1001 / 1000 / 30 * 1_000_000)
|
|
36
|
+
result = SCCWriter._format_timestamp_df(us_for_frame_1800)
|
|
37
|
+
assert result == "00:01:00;02"
|
|
38
|
+
|
|
39
|
+
def test_df_no_frame_skip_at_10_minute_boundary(self):
|
|
40
|
+
# At 10-minute boundaries, no frames are dropped
|
|
41
|
+
twenty_minutes_us = 20 * 60 * 1_000_000
|
|
42
|
+
result = SCCWriter._format_timestamp_df(twenty_minutes_us)
|
|
43
|
+
assert result == "00:20:00;00"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestSCCWriterDropFrameFlag:
|
|
47
|
+
def _make_simple_captions(self):
|
|
48
|
+
srt = (
|
|
49
|
+
"1\n"
|
|
50
|
+
"00:00:01,000 --> 00:00:03,000\n"
|
|
51
|
+
"Hello world\n\n"
|
|
52
|
+
"2\n"
|
|
53
|
+
"00:00:05,000 --> 00:00:07,000\n"
|
|
54
|
+
"Second line\n"
|
|
55
|
+
)
|
|
56
|
+
return SRTReader().read(srt)
|
|
57
|
+
|
|
58
|
+
def test_drop_frame_false_is_default(self):
|
|
59
|
+
writer = SCCWriter()
|
|
60
|
+
assert writer.drop_frame is False
|
|
61
|
+
|
|
62
|
+
def test_drop_frame_false_uses_colons(self):
|
|
63
|
+
captions = self._make_simple_captions()
|
|
64
|
+
output = SCCWriter(drop_frame=False).write(captions)
|
|
65
|
+
timestamps = re.findall(r"\d{2}:\d{2}:\d{2}[:;]\d{2}", output)
|
|
66
|
+
for ts in timestamps:
|
|
67
|
+
assert ";" not in ts
|
|
68
|
+
|
|
69
|
+
def test_drop_frame_true_uses_semicolons(self):
|
|
70
|
+
captions = self._make_simple_captions()
|
|
71
|
+
output = SCCWriter(drop_frame=True).write(captions)
|
|
72
|
+
timestamps = re.findall(r"\d{2}:\d{2}:\d{2}[:;]\d{2}", output)
|
|
73
|
+
assert len(timestamps) > 0
|
|
74
|
+
for ts in timestamps:
|
|
75
|
+
assert ";" in ts
|
|
76
|
+
|
|
77
|
+
def test_drop_frame_false_preserves_existing_behavior(self):
|
|
78
|
+
captions = self._make_simple_captions()
|
|
79
|
+
output_default = SCCWriter().write(captions)
|
|
80
|
+
output_explicit = SCCWriter(drop_frame=False).write(captions)
|
|
81
|
+
assert output_default == output_explicit
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TestSCCWriterTimestampOrdering:
|
|
85
|
+
def test_timestamps_monotonically_increasing_ndf(self):
|
|
86
|
+
vtt_input = (
|
|
87
|
+
"WEBVTT\n\n"
|
|
88
|
+
"0\n00:00:01.529 --> 00:00:03.640\n"
|
|
89
|
+
"When it comes to finding the one,\n\n"
|
|
90
|
+
"1\n00:00:03.730 --> 00:00:07.239\n"
|
|
91
|
+
"I always say that if they can love you at your messiest,\n\n"
|
|
92
|
+
"2\n00:00:07.570 --> 00:00:09.319\n"
|
|
93
|
+
"calm you at your moodiest,\n\n"
|
|
94
|
+
"3\n00:00:09.529 --> 00:00:11.680\n"
|
|
95
|
+
"and laugh with you at your quirkiest,\n\n"
|
|
96
|
+
"4\n00:00:11.930 --> 00:00:13.760\n"
|
|
97
|
+
"you've probably found your person.\n"
|
|
98
|
+
)
|
|
99
|
+
captions = WebVTTReader().read(vtt_input)
|
|
100
|
+
output = SCCWriter(drop_frame=False).write(captions)
|
|
101
|
+
timestamps = re.findall(r"(\d{2}:\d{2}:\d{2}:\d{2})", output)
|
|
102
|
+
for i in range(1, len(timestamps)):
|
|
103
|
+
assert (
|
|
104
|
+
timestamps[i] >= timestamps[i - 1]
|
|
105
|
+
), f"NDF timestamps out of order: {timestamps[i - 1]} > {timestamps[i]}"
|
|
106
|
+
|
|
107
|
+
def test_timestamps_monotonically_increasing_df(self):
|
|
108
|
+
vtt_input = (
|
|
109
|
+
"WEBVTT\n\n"
|
|
110
|
+
"0\n00:00:01.529 --> 00:00:03.640\n"
|
|
111
|
+
"When it comes to finding the one,\n\n"
|
|
112
|
+
"1\n00:00:03.730 --> 00:00:07.239\n"
|
|
113
|
+
"I always say that if they can love you at your messiest,\n\n"
|
|
114
|
+
"2\n00:00:07.570 --> 00:00:09.319\n"
|
|
115
|
+
"calm you at your moodiest,\n\n"
|
|
116
|
+
"3\n00:00:09.529 --> 00:00:11.680\n"
|
|
117
|
+
"and laugh with you at your quirkiest,\n\n"
|
|
118
|
+
"4\n00:00:11.930 --> 00:00:13.760\n"
|
|
119
|
+
"you've probably found your person.\n"
|
|
120
|
+
)
|
|
121
|
+
captions = WebVTTReader().read(vtt_input)
|
|
122
|
+
output = SCCWriter(drop_frame=True).write(captions)
|
|
123
|
+
timestamps = re.findall(r"(\d{2}:\d{2}:\d{2};\d{2})", output)
|
|
124
|
+
for i in range(1, len(timestamps)):
|
|
125
|
+
assert (
|
|
126
|
+
timestamps[i] >= timestamps[i - 1]
|
|
127
|
+
), f"DF timestamps out of order: {timestamps[i - 1]} > {timestamps[i]}"
|
|
128
|
+
|
|
129
|
+
def test_rapid_short_captions_stay_ordered(self):
|
|
130
|
+
"""Short text followed by long text should not cause timestamp inversion."""
|
|
131
|
+
vtt_input = (
|
|
132
|
+
"WEBVTT\n\n"
|
|
133
|
+
"0\n00:00:02.200 --> 00:00:02.359\nyou know,\n\n"
|
|
134
|
+
"1\n00:00:02.400 --> 00:00:03.760\n"
|
|
135
|
+
"the way he kind of looked at me.\n\n"
|
|
136
|
+
"2\n00:00:04.700 --> 00:00:05.169\nAnd I said,\n\n"
|
|
137
|
+
"3\n00:00:05.210 --> 00:00:05.520\noh\n"
|
|
138
|
+
)
|
|
139
|
+
captions = WebVTTReader().read(vtt_input)
|
|
140
|
+
for df in (True, False):
|
|
141
|
+
output = SCCWriter(drop_frame=df).write(captions)
|
|
142
|
+
sep = ";" if df else ":"
|
|
143
|
+
pattern = r"\d{2}:\d{2}:\d{2}" + re.escape(sep) + r"\d{2}"
|
|
144
|
+
timestamps = re.findall(pattern, output)
|
|
145
|
+
for i in range(1, len(timestamps)):
|
|
146
|
+
assert (
|
|
147
|
+
timestamps[i] >= timestamps[i - 1]
|
|
148
|
+
), f"drop_frame={df}: {timestamps[i - 1]} > {timestamps[i]}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestSCCWriterFirstCueBackshift:
|
|
152
|
+
def test_first_cue_start_is_shifted_back(self):
|
|
153
|
+
srt = "1\n" "00:00:10,000 --> 00:00:12,000\n" "Hello world\n"
|
|
154
|
+
captions = SRTReader().read(srt)
|
|
155
|
+
output = SCCWriter(drop_frame=False).write(captions)
|
|
156
|
+
timestamps = re.findall(r"(\d{2}:\d{2}:\d{2}:\d{2})", output)
|
|
157
|
+
# The first timestamp should be earlier than 00:00:09:29
|
|
158
|
+
# (10s real time -> ~9:29 NDF, minus backshift)
|
|
159
|
+
assert timestamps[0] < "00:00:09:29"
|
|
160
|
+
|
|
161
|
+
def test_first_cue_at_zero_does_not_go_negative(self):
|
|
162
|
+
srt = "1\n" "00:00:00,100 --> 00:00:02,000\n" "Hello\n"
|
|
163
|
+
captions = SRTReader().read(srt)
|
|
164
|
+
output = SCCWriter(drop_frame=False).write(captions)
|
|
165
|
+
timestamps = re.findall(r"(\d{2}:\d{2}:\d{2}:\d{2})", output)
|
|
166
|
+
assert timestamps[0] == "00:00:00:00"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestSCCWriterOverlappingCues:
|
|
170
|
+
def test_overlapping_cues_suppress_clear_screen(self):
|
|
171
|
+
"""When cues are very close together, the clear-screen (942c) for
|
|
172
|
+
the previous cue should be suppressed."""
|
|
173
|
+
srt = (
|
|
174
|
+
"1\n00:00:01,000 --> 00:00:01,900\nFirst\n\n"
|
|
175
|
+
"2\n00:00:02,000 --> 00:00:03,000\nSecond\n"
|
|
176
|
+
)
|
|
177
|
+
captions = SRTReader().read(srt)
|
|
178
|
+
output = SCCWriter(drop_frame=False).write(captions)
|
|
179
|
+
# Count standalone 942c lines (clear-screen commands between captions)
|
|
180
|
+
clear_lines = [
|
|
181
|
+
line
|
|
182
|
+
for line in output.split("\n")
|
|
183
|
+
if line.strip().endswith("942c 942c") and "94ae" not in line
|
|
184
|
+
]
|
|
185
|
+
# With tight cues, the first cue's clear should be suppressed
|
|
186
|
+
# (only the last cue gets a clear-screen at its end time)
|
|
187
|
+
assert len(clear_lines) <= 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TestSCCWriterSplitLongCaption:
|
|
191
|
+
def test_split_caption_exceeding_80_tokens(self):
|
|
192
|
+
"""A caption that would exceed 80 SCC tokens should be split."""
|
|
193
|
+
# Create a very long caption that will produce many code tokens
|
|
194
|
+
long_text = "A" * 32 + "\n" + "B" * 32 + "\n" + "C" * 32 + "\n" + "D" * 32
|
|
195
|
+
srt = "1\n" "00:00:05,000 --> 00:00:10,000\n" f"{long_text}\n"
|
|
196
|
+
captions = SRTReader().read(srt)
|
|
197
|
+
output = SCCWriter(drop_frame=False).write(captions)
|
|
198
|
+
# Each output line (non-empty, non-header) should have <= 80 tokens
|
|
199
|
+
for line in output.split("\n"):
|
|
200
|
+
line = line.strip()
|
|
201
|
+
if not line or line == "Scenarist_SCC V1.0":
|
|
202
|
+
continue
|
|
203
|
+
# Line format: "HH:MM:SS:FF\t<tokens>"
|
|
204
|
+
parts = line.split("\t")
|
|
205
|
+
if len(parts) == 2:
|
|
206
|
+
tokens = parts[1].split()
|
|
207
|
+
assert len(tokens) <= SCC_TOKENS_PER_CAPTION_MAX, (
|
|
208
|
+
f"Line has {len(tokens)} tokens, "
|
|
209
|
+
f"exceeds {SCC_TOKENS_PER_CAPTION_MAX}"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class TestSCCWriterRoundTrip:
|
|
214
|
+
def test_srt_to_scc_roundtrip_ndf(self):
|
|
215
|
+
srt = (
|
|
216
|
+
"1\n00:00:01,000 --> 00:00:03,000\nHello world\n\n"
|
|
217
|
+
"2\n00:00:05,000 --> 00:00:07,000\nGoodbye world\n"
|
|
218
|
+
)
|
|
219
|
+
captions = SRTReader().read(srt)
|
|
220
|
+
scc_output = SCCWriter(drop_frame=False).write(captions)
|
|
221
|
+
# Should be readable by SCCReader
|
|
222
|
+
result = SCCReader().read(scc_output)
|
|
223
|
+
assert not result.is_empty()
|
|
224
|
+
assert len(result.get_captions("en-US")) == 2
|
|
225
|
+
|
|
226
|
+
def test_srt_to_scc_roundtrip_df(self):
|
|
227
|
+
srt = (
|
|
228
|
+
"1\n00:00:01,000 --> 00:00:03,000\nHello world\n\n"
|
|
229
|
+
"2\n00:00:05,000 --> 00:00:07,000\nGoodbye world\n"
|
|
230
|
+
)
|
|
231
|
+
captions = SRTReader().read(srt)
|
|
232
|
+
scc_output = SCCWriter(drop_frame=True).write(captions)
|
|
233
|
+
# SCCReader already supports semicolon (DF) timestamps
|
|
234
|
+
result = SCCReader().read(scc_output)
|
|
235
|
+
assert not result.is_empty()
|
|
236
|
+
assert len(result.get_captions("en-US")) == 2
|
|
237
|
+
|
|
238
|
+
def test_webvtt_to_scc_roundtrip_df(self):
|
|
239
|
+
vtt_input = (
|
|
240
|
+
"WEBVTT\n\n"
|
|
241
|
+
"0\n00:00:01.529 --> 00:00:03.640\n"
|
|
242
|
+
"When it comes to finding the one,\n\n"
|
|
243
|
+
"1\n00:00:03.730 --> 00:00:07.239\n"
|
|
244
|
+
"I always say that if they can love you\n\n"
|
|
245
|
+
"2\n00:00:07.570 --> 00:00:09.319\n"
|
|
246
|
+
"calm you at your moodiest,\n"
|
|
247
|
+
)
|
|
248
|
+
captions = WebVTTReader().read(vtt_input)
|
|
249
|
+
scc_output = SCCWriter(drop_frame=True).write(captions)
|
|
250
|
+
result = SCCReader().read(scc_output)
|
|
251
|
+
assert not result.is_empty()
|
|
252
|
+
assert len(result.get_captions("en-US")) == 3
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
from pycaption.scc import SCC_TOKENS_PER_CAPTION_MAX # noqa: E402
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|