pycaption 2.2.23__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.23/pycaption.egg-info → pycaption-2.2.25}/PKG-INFO +1 -1
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/scc/__init__.py +107 -21
- {pycaption-2.2.23 → pycaption-2.2.25/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption.egg-info/SOURCES.txt +1 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/setup.py +1 -1
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/conftest.py +1 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/fixtures/scc.py +10 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_scc.py +18 -0
- pycaption-2.2.25/tests/test_scc_writer.py +255 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/LICENSE +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/MANIFEST.in +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/README.rst +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/__init__.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/base.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/dfxp/base.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/exceptions.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/geometry.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/microdvd.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/sami.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/scc/constants.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/scc/specialized_collections.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/scc/translator.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/srt.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/transcript.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/utils.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption/webvtt.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/setup.cfg +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/__init__.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/fixtures/sami.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/fixtures/srt.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/mixins.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_base.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_dfxp.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_functions.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_geometry.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_microdvd.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_sami.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_scc_translator.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_srt.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_webvtt.py +0 -0
- {pycaption-2.2.23 → pycaption-2.2.25}/tests/test_webvtt_conversion.py +0 -0
|
@@ -332,7 +332,14 @@ class SCCReader(BaseReader):
|
|
|
332
332
|
for idx, word in enumerate(word_list):
|
|
333
333
|
word = word.strip()
|
|
334
334
|
if len(word) == 4:
|
|
335
|
-
|
|
335
|
+
# Look ahead for the next command, skipping the duplicate
|
|
336
|
+
# that SCC uses for error-correction (same word repeated).
|
|
337
|
+
next_idx = idx + 1
|
|
338
|
+
if next_idx < len(word_list) and word_list[next_idx].strip() == word:
|
|
339
|
+
next_idx += 1
|
|
340
|
+
next_command = (
|
|
341
|
+
word_list[next_idx] if next_idx < len(word_list) else None
|
|
342
|
+
)
|
|
336
343
|
self._translate_word(word=word, next_command=next_command)
|
|
337
344
|
|
|
338
345
|
def _translate_word(self, word, next_command=None):
|
|
@@ -488,10 +495,11 @@ class SCCReader(BaseReader):
|
|
|
488
495
|
edm_time = self.time_translator.get_time()
|
|
489
496
|
if self.pop_ons_queue:
|
|
490
497
|
self._pop_on(end=edm_time)
|
|
491
|
-
if
|
|
492
|
-
|
|
493
|
-
self.
|
|
494
|
-
|
|
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)
|
|
495
503
|
self.buffer = self.node_creator_factory.new_creator()
|
|
496
504
|
self.node_creator_factory.position_tracker.reset_for_new_caption()
|
|
497
505
|
self.time = edm_time
|
|
@@ -555,9 +563,17 @@ class SCCReader(BaseReader):
|
|
|
555
563
|
self.caption_stash.create_and_store(pop_on_cue.buffer, pop_on_cue.start, end)
|
|
556
564
|
|
|
557
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
|
+
|
|
558
573
|
class SCCWriter(BaseWriter):
|
|
559
|
-
def __init__(self, *args, **kw):
|
|
574
|
+
def __init__(self, *args, drop_frame=False, **kw):
|
|
560
575
|
super().__init__(*args, **kw)
|
|
576
|
+
self.drop_frame = drop_frame
|
|
561
577
|
|
|
562
578
|
def write(self, caption_set):
|
|
563
579
|
output = HEADER + "\n\n"
|
|
@@ -581,24 +597,64 @@ class SCCWriter(BaseWriter):
|
|
|
581
597
|
# Advance start times so as to have time to write to the pop-on
|
|
582
598
|
# buffer; possibly remove the previous clear-screen command
|
|
583
599
|
for index, (code, start, end) in enumerate(codes):
|
|
584
|
-
code_words = len(code)
|
|
600
|
+
code_words = len(code.split()) + _SCC_OVERHEAD
|
|
585
601
|
code_time_microseconds = code_words * MICROSECONDS_PER_CODEWORD
|
|
586
602
|
code_start = start - code_time_microseconds
|
|
587
|
-
if
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
+
):
|
|
592
622
|
codes[index - 1] = (previous_code, previous_start, None)
|
|
593
|
-
|
|
623
|
+
codes[index] = (code, code_start, end)
|
|
594
624
|
|
|
595
625
|
# PASS 3:
|
|
596
|
-
#
|
|
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
|
|
597
639
|
for code, start, end in codes:
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
+
|
|
602
658
|
if end is not None:
|
|
603
659
|
output += f"{self._format_timestamp(end)}\t942c 942c\n\n"
|
|
604
660
|
|
|
@@ -660,10 +716,20 @@ class SCCWriter(BaseWriter):
|
|
|
660
716
|
code = self._maybe_align(code)
|
|
661
717
|
return code
|
|
662
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
|
+
|
|
663
730
|
@staticmethod
|
|
664
|
-
def
|
|
665
|
-
seconds_float = microseconds /
|
|
666
|
-
# Convert to non-drop-frame timecode
|
|
731
|
+
def _format_timestamp_ndf(microseconds):
|
|
732
|
+
seconds_float = microseconds / 1_000_000.0
|
|
667
733
|
seconds_float *= 1000.0 / 1001.0
|
|
668
734
|
hours = math.floor(seconds_float / 3600)
|
|
669
735
|
seconds_float -= hours * 3600
|
|
@@ -674,6 +740,26 @@ class SCCWriter(BaseWriter):
|
|
|
674
740
|
frames = math.floor(seconds_float * 30)
|
|
675
741
|
return f"{hours:02}:{minutes:02}:{seconds:02}:{frames:02}"
|
|
676
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
|
+
|
|
677
763
|
|
|
678
764
|
class _SccTimeTranslator:
|
|
679
765
|
"""Converts SCC time to microseconds, keeping track of frames passed"""
|
|
@@ -91,6 +91,7 @@ from tests.fixtures.scc import ( # noqa: F401
|
|
|
91
91
|
sample_no_positioning_at_all_scc,
|
|
92
92
|
sample_scc_created_dfxp_with_wrongly_closing_spans,
|
|
93
93
|
sample_scc_duplicate_special_characters,
|
|
94
|
+
sample_scc_doubled_mid_row_before_punctuation,
|
|
94
95
|
sample_scc_duplicate_tab_offset,
|
|
95
96
|
sample_scc_empty,
|
|
96
97
|
sample_scc_eoc_first_command,
|
|
@@ -659,3 +659,13 @@ Scenarist_SCC V1.0
|
|
|
659
659
|
00:00:04;00 942c
|
|
660
660
|
|
|
661
661
|
"""
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@pytest.fixture(scope="session")
|
|
665
|
+
def sample_scc_doubled_mid_row_before_punctuation():
|
|
666
|
+
return """\
|
|
667
|
+
Scenarist_SCC V1.0
|
|
668
|
+
|
|
669
|
+
00:26:48;29\t9420 9420 94d0 94d0 97a1 97a1 3e3e 2057 e5a7 ecec 2062 e520 6261 e36b 206e e5f8 f420 f7e5 e56b 20f7 e9f4 6880 9470 9470 616e eff4 68e5 f220 e570 e973 ef64 e520 efe6 91ae 91ae 4361 6e61 6461 2046 e9ec e573 9120 9120 ae80 942c 942c 8080 8080 942f 942f
|
|
670
|
+
|
|
671
|
+
"""
|
|
@@ -392,6 +392,24 @@ class TestSCCReader(ReaderTestingMixIn):
|
|
|
392
392
|
]
|
|
393
393
|
assert expected_lines == actual_lines
|
|
394
394
|
|
|
395
|
+
def test_doubled_mid_row_before_punctuation_no_extra_space(
|
|
396
|
+
self,
|
|
397
|
+
sample_scc_doubled_mid_row_before_punctuation,
|
|
398
|
+
):
|
|
399
|
+
caption_set = SCCReader().read(
|
|
400
|
+
sample_scc_doubled_mid_row_before_punctuation
|
|
401
|
+
)
|
|
402
|
+
captions = caption_set.get_captions("en-US")
|
|
403
|
+
text_nodes = [
|
|
404
|
+
node.content
|
|
405
|
+
for cap_ in captions
|
|
406
|
+
for node in cap_.nodes
|
|
407
|
+
if node.type_ == CaptionNode.TEXT
|
|
408
|
+
]
|
|
409
|
+
full_text = "".join(text_nodes)
|
|
410
|
+
assert " ." not in full_text
|
|
411
|
+
assert full_text.endswith("Files.")
|
|
412
|
+
|
|
395
413
|
def test_removing_spaces_at_end_of_lines(
|
|
396
414
|
self,
|
|
397
415
|
sample_scc_with_spaces_at_eol_pop,
|
|
@@ -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
|