pycaption 2.2.24__tar.gz → 2.2.26__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.26}/PKG-INFO +1 -1
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/geometry.py +10 -4
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/__init__.py +100 -22
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/specialized_collections.py +10 -6
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/webvtt.py +170 -13
- {pycaption-2.2.24 → pycaption-2.2.26/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption.egg-info/SOURCES.txt +1 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/setup.py +1 -1
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_scc.py +4 -4
- pycaption-2.2.26/tests/test_scc_writer.py +255 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_webvtt.py +186 -12
- {pycaption-2.2.24 → pycaption-2.2.26}/LICENSE +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/MANIFEST.in +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/README.rst +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/__init__.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/base.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/dfxp/base.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/exceptions.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/microdvd.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/sami.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/constants.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/scc/translator.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/srt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/transcript.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption/utils.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/setup.cfg +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/__init__.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/conftest.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/dfxp.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/sami.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/scc.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/srt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/fixtures/webvtt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/mixins.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_base.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_dfxp.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_functions.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_geometry.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_microdvd.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_sami.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_sami_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_scc_translator.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_srt.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_srt_conversion.py +0 -0
- {pycaption-2.2.24 → pycaption-2.2.26}/tests/test_webvtt_conversion.py +0 -0
|
@@ -213,9 +213,15 @@ class Stretch(TwoDimensionalObject):
|
|
|
213
213
|
|
|
214
214
|
|
|
215
215
|
class Region:
|
|
216
|
-
"""Represents the spatial coordinates of a rectangle
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
"""Represents the spatial coordinates of a rectangle.
|
|
217
|
+
|
|
218
|
+
NOTE: This class is currently dead code — not imported or used anywhere in
|
|
219
|
+
the codebase. It also contains known bugs (align_from_origin returns
|
|
220
|
+
inconsistent types, add_extent method does not exist on Point). We left it
|
|
221
|
+
here because it may serve as a starting point if we ever need geometric
|
|
222
|
+
rectangle operations beyond what Layout class provides (e.g. region
|
|
223
|
+
intersection or point-in-region checks).
|
|
224
|
+
If we'll ever need it, fix the bugs first.
|
|
219
225
|
"""
|
|
220
226
|
|
|
221
227
|
@classmethod
|
|
@@ -846,7 +852,7 @@ class Layout:
|
|
|
846
852
|
found_absolute_unit = False
|
|
847
853
|
if bottom_right.x.unit != UnitEnum.PERCENT:
|
|
848
854
|
found_absolute_unit = True
|
|
849
|
-
elif bottom_right.
|
|
855
|
+
elif bottom_right.y.unit != UnitEnum.PERCENT:
|
|
850
856
|
found_absolute_unit = True
|
|
851
857
|
|
|
852
858
|
if found_absolute_unit:
|
|
@@ -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"""
|
|
@@ -26,6 +26,9 @@ from .constants import (
|
|
|
26
26
|
|
|
27
27
|
PopOnCue = collections.namedtuple("PopOnCue", "buffer, start, end")
|
|
28
28
|
|
|
29
|
+
# First two hex chars of SCC codes that produce punctuation ['.', '!', '?', ',']
|
|
30
|
+
_PUNCTUATION_PREFIXES = frozenset(["ae", "a1", "bf", "2c"])
|
|
31
|
+
|
|
29
32
|
|
|
30
33
|
class PreCaption:
|
|
31
34
|
"""
|
|
@@ -444,8 +447,7 @@ class InstructionNodeCreator:
|
|
|
444
447
|
# handle mid-row codes that follows a text node
|
|
445
448
|
# don't add space if the next command adds one of
|
|
446
449
|
# ['.', '!', '?', ',']
|
|
447
|
-
|
|
448
|
-
next_is_punctuation = next_command and next_command[:2] in punctuation
|
|
450
|
+
next_is_punctuation = next_command and next_command[:2] in _PUNCTUATION_PREFIXES
|
|
449
451
|
prev_text_node = self.get_previous_text_node()
|
|
450
452
|
prev_node_is_break = prev_text_node is not None and any(
|
|
451
453
|
x.is_explicit_break()
|
|
@@ -501,9 +503,9 @@ class InstructionNodeCreator:
|
|
|
501
503
|
if len(collection) == 0:
|
|
502
504
|
return False
|
|
503
505
|
for element in collection[::-1]:
|
|
504
|
-
if element._type ==
|
|
506
|
+
if element._type == _InstructionNode.TEXT:
|
|
505
507
|
return False
|
|
506
|
-
elif element._type ==
|
|
508
|
+
elif element._type == _InstructionNode.BREAK:
|
|
507
509
|
return True
|
|
508
510
|
return False
|
|
509
511
|
|
|
@@ -782,6 +784,8 @@ def _format_italics(collection):
|
|
|
782
784
|
|
|
783
785
|
|
|
784
786
|
def _remove_spaces_at_end_of_the_line(collection):
|
|
787
|
+
if not collection:
|
|
788
|
+
return collection
|
|
785
789
|
for idx, node in enumerate(collection):
|
|
786
790
|
if (
|
|
787
791
|
idx > 0
|
|
@@ -826,7 +830,7 @@ def _remove_noop_on_off_italics(collection):
|
|
|
826
830
|
return new_collection
|
|
827
831
|
|
|
828
832
|
|
|
829
|
-
def
|
|
833
|
+
def _remove_noop_off_on_italics(collection):
|
|
830
834
|
"""Removes pairs of off-on italics nodes, that don't surround any other
|
|
831
835
|
node
|
|
832
836
|
|
|
@@ -867,7 +871,7 @@ def _remove_noop_italics(collection):
|
|
|
867
871
|
"""
|
|
868
872
|
new_collection = _remove_noop_on_off_italics(collection)
|
|
869
873
|
|
|
870
|
-
new_collection =
|
|
874
|
+
new_collection = _remove_noop_off_on_italics(new_collection)
|
|
871
875
|
|
|
872
876
|
return new_collection
|
|
873
877
|
|
|
@@ -10,7 +10,7 @@ from .exceptions import (
|
|
|
10
10
|
CaptionReadSyntaxError,
|
|
11
11
|
InvalidInputError,
|
|
12
12
|
)
|
|
13
|
-
from .geometry import HorizontalAlignmentEnum, Layout
|
|
13
|
+
from .geometry import HorizontalAlignmentEnum, Layout, Point, Size, Stretch, UnitEnum
|
|
14
14
|
|
|
15
15
|
# A WebVTT timing line has both start/end times and layout related settings
|
|
16
16
|
# (referred to as 'cue settings' in the documentation)
|
|
@@ -21,6 +21,19 @@ VOICE_SPAN_PATTERN = re.compile("<v(\\.\\w+)* ([^>]*)>")
|
|
|
21
21
|
OTHER_SPAN_PATTERN = re.compile(
|
|
22
22
|
r"</?([cibuv]|ruby|rt|lang|(\d+):(\d{2})(:\d{2})?\.(\d{3})).*?>"
|
|
23
23
|
) # These WebVTT tags are stripped off the cues on conversion
|
|
24
|
+
REGION_SETTING_PATTERN = re.compile(r"^([\w]+):(.+)$")
|
|
25
|
+
"""
|
|
26
|
+
Matches a setting name (word chars) followed by colon and a value:
|
|
27
|
+
id:region1
|
|
28
|
+
width:50%
|
|
29
|
+
"""
|
|
30
|
+
REGION_ANCHOR_PATTERN = re.compile(r"^(\d+(?:\.\d+)?)%,(\d+(?:\.\d+)?)%$")
|
|
31
|
+
"""
|
|
32
|
+
Matches two percentage values (integer or decimal) separated by a comma:
|
|
33
|
+
0%,0%
|
|
34
|
+
100%,100%
|
|
35
|
+
"""
|
|
36
|
+
LINE_HEIGHT_VH = 5.33
|
|
24
37
|
|
|
25
38
|
WEBVTT_VERSION_OF = {
|
|
26
39
|
HorizontalAlignmentEnum.LEFT: "left",
|
|
@@ -69,6 +82,8 @@ class WebVTTReader(BaseReader):
|
|
|
69
82
|
return caption_set
|
|
70
83
|
|
|
71
84
|
def _parse(self, lines):
|
|
85
|
+
# State machine: cycles through waiting-for-timing → collecting-text
|
|
86
|
+
# → emit-caption-on-blank-line, repeat.
|
|
72
87
|
captions = CaptionList()
|
|
73
88
|
start = None
|
|
74
89
|
end = None
|
|
@@ -76,8 +91,13 @@ class WebVTTReader(BaseReader):
|
|
|
76
91
|
layout_info = None
|
|
77
92
|
found_timing = False
|
|
78
93
|
|
|
94
|
+
# Parse REGION blocks from the header area before processing cues
|
|
95
|
+
self._regions = self._parse_regions(lines)
|
|
96
|
+
|
|
79
97
|
for i, line in enumerate(lines):
|
|
80
98
|
if "-->" in line:
|
|
99
|
+
# Timing line found (e.g. "00:00:01.000 --> 00:00:03.000")
|
|
100
|
+
# marks the start of a new cue
|
|
81
101
|
found_timing = True
|
|
82
102
|
timing_line = i
|
|
83
103
|
last_start_time = captions[-1].start if captions else 0
|
|
@@ -91,6 +111,8 @@ class WebVTTReader(BaseReader):
|
|
|
91
111
|
raise type(e)(new_msg).with_traceback(tb) from None
|
|
92
112
|
|
|
93
113
|
elif "" == line:
|
|
114
|
+
# Blank line = block separator in WebVTT.
|
|
115
|
+
# If we were collecting a cue, finalize and store it.
|
|
94
116
|
if found_timing and nodes:
|
|
95
117
|
found_timing = False
|
|
96
118
|
caption = Caption(start, end, nodes, layout_info=layout_info)
|
|
@@ -98,24 +120,23 @@ class WebVTTReader(BaseReader):
|
|
|
98
120
|
nodes = []
|
|
99
121
|
else:
|
|
100
122
|
if found_timing:
|
|
123
|
+
# We're inside a cue — this line is cue text.
|
|
124
|
+
# Add a line break between multi-line cue text.
|
|
101
125
|
if nodes:
|
|
102
126
|
nodes.append(CaptionNode.create_break())
|
|
103
127
|
nodes.append(CaptionNode.create_text(self._decode(line)))
|
|
104
128
|
else:
|
|
105
|
-
#
|
|
129
|
+
# Outside a cue: cue identifiers, NOTE blocks,
|
|
130
|
+
# or other metadata — skip silently.
|
|
106
131
|
pass
|
|
107
132
|
|
|
108
|
-
#
|
|
133
|
+
# File may not end with a blank line — emit any remaining cue
|
|
109
134
|
if nodes:
|
|
110
135
|
caption = Caption(start, end, nodes, layout_info=layout_info)
|
|
111
136
|
captions.append(caption)
|
|
112
137
|
|
|
113
138
|
return captions
|
|
114
139
|
|
|
115
|
-
def _remove_styles(self, line):
|
|
116
|
-
partial_result = VOICE_SPAN_PATTERN.sub("\\2: ", line)
|
|
117
|
-
return OTHER_SPAN_PATTERN.sub("", partial_result)
|
|
118
|
-
|
|
119
140
|
def _validate_timings(self, start, end, last_start_time):
|
|
120
141
|
if start is None:
|
|
121
142
|
raise CaptionReadSyntaxError("Invalid cue start timestamp.")
|
|
@@ -147,7 +168,18 @@ class WebVTTReader(BaseReader):
|
|
|
147
168
|
|
|
148
169
|
layout_info = None
|
|
149
170
|
if cue_settings:
|
|
150
|
-
|
|
171
|
+
region_id = self._extract_region_id(cue_settings)
|
|
172
|
+
if region_id and region_id in self._regions:
|
|
173
|
+
layout_info = self._regions[region_id]
|
|
174
|
+
layout_info = Layout(
|
|
175
|
+
origin=layout_info.origin,
|
|
176
|
+
extent=layout_info.extent,
|
|
177
|
+
padding=layout_info.padding,
|
|
178
|
+
alignment=layout_info.alignment,
|
|
179
|
+
webvtt_positioning=cue_settings,
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
layout_info = Layout(webvtt_positioning=cue_settings)
|
|
151
183
|
|
|
152
184
|
return start, end, layout_info
|
|
153
185
|
|
|
@@ -189,12 +221,138 @@ class WebVTTReader(BaseReader):
|
|
|
189
221
|
s = s.replace("&", "&")
|
|
190
222
|
return s
|
|
191
223
|
|
|
224
|
+
def _parse_regions(self, lines):
|
|
225
|
+
"""Parse REGION blocks from the file header area.
|
|
226
|
+
|
|
227
|
+
A WebVTT region defines a named rectangular area on screen where cues
|
|
228
|
+
can be rendered. Regions appear before any cues with the syntax:
|
|
229
|
+
|
|
230
|
+
REGION
|
|
231
|
+
id:subtitle_area
|
|
232
|
+
width:50%
|
|
233
|
+
lines:3
|
|
234
|
+
regionanchor:0%,100%
|
|
235
|
+
viewportanchor:10%,90%
|
|
236
|
+
scroll:up
|
|
237
|
+
|
|
238
|
+
Supported settings:
|
|
239
|
+
id - unique identifier (required)
|
|
240
|
+
width - region width as percentage (default: 100%)
|
|
241
|
+
lines - visible line count (default: 3)
|
|
242
|
+
regionanchor - anchor point within region as x%,y% (default: 0%,100%)
|
|
243
|
+
viewportanchor - anchor point on viewport as x%,y% (default: 0%,100%)
|
|
244
|
+
scroll - scroll behavior, only "up" is valid (default: none)
|
|
245
|
+
|
|
246
|
+
:returns: dict mapping region id -> Layout
|
|
247
|
+
"""
|
|
248
|
+
regions = {}
|
|
249
|
+
i = 0
|
|
250
|
+
while i < len(lines):
|
|
251
|
+
line = lines[i].strip()
|
|
252
|
+
if line == "REGION" or line.startswith(("REGION\t", "REGION ")):
|
|
253
|
+
i += 1
|
|
254
|
+
settings = {}
|
|
255
|
+
seen_keys = set()
|
|
256
|
+
# Read settings until a blank line (block separator in WebVTT)
|
|
257
|
+
while i < len(lines) and lines[i].strip() != "":
|
|
258
|
+
# Match key:value pair (e.g. "width:50%")
|
|
259
|
+
m = REGION_SETTING_PATTERN.match(lines[i].strip())
|
|
260
|
+
if m:
|
|
261
|
+
key, value = m.group(1), m.group(2)
|
|
262
|
+
if key in seen_keys:
|
|
263
|
+
i += 1
|
|
264
|
+
continue
|
|
265
|
+
seen_keys.add(key)
|
|
266
|
+
settings[key] = value
|
|
267
|
+
i += 1
|
|
268
|
+
# Skip regions without id (spec requires it; cues can't reference them)
|
|
269
|
+
if "id" in settings:
|
|
270
|
+
region_id = settings["id"]
|
|
271
|
+
# First definition wins; duplicates are ignored (RULE-REG-009)
|
|
272
|
+
if region_id not in regions:
|
|
273
|
+
regions[region_id] = self._region_to_layout(settings)
|
|
274
|
+
elif "-->" in line:
|
|
275
|
+
# REGIONs only appear before cues; stop scanning once cues begin
|
|
276
|
+
break
|
|
277
|
+
else:
|
|
278
|
+
i += 1
|
|
279
|
+
return regions
|
|
280
|
+
|
|
281
|
+
def _region_to_layout(self, settings):
|
|
282
|
+
"""Convert parsed region settings dict into a Layout with origin/extent.
|
|
283
|
+
|
|
284
|
+
Uses W3C TTML-WebVTT mapping formulas:
|
|
285
|
+
origin_x = viewportanchor_x - (regionanchor_x / 100 * width)
|
|
286
|
+
origin_y = viewportanchor_y - (regionanchor_y / 100 * height)
|
|
287
|
+
height = lines * 5.33
|
|
288
|
+
"""
|
|
289
|
+
# Spec defaults per W3C WebVTT §6
|
|
290
|
+
width = 100.0
|
|
291
|
+
lines = 3
|
|
292
|
+
regionanchor_x, regionanchor_y = 0.0, 100.0
|
|
293
|
+
viewportanchor_x, viewportanchor_y = 0.0, 100.0
|
|
294
|
+
# Parse each setting, falling back to defaults on invalid values
|
|
295
|
+
if "width" in settings:
|
|
296
|
+
try:
|
|
297
|
+
width = float(settings["width"].rstrip("%"))
|
|
298
|
+
except ValueError:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
if "lines" in settings:
|
|
302
|
+
try:
|
|
303
|
+
lines = int(settings["lines"])
|
|
304
|
+
except ValueError:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
# regionanchor: which point inside the region is "pinned"
|
|
308
|
+
# e.g. 0%,100% means the bottom-left corner of the region
|
|
309
|
+
if "regionanchor" in settings:
|
|
310
|
+
m = REGION_ANCHOR_PATTERN.match(settings["regionanchor"])
|
|
311
|
+
if m:
|
|
312
|
+
regionanchor_x = float(m.group(1))
|
|
313
|
+
regionanchor_y = float(m.group(2))
|
|
314
|
+
|
|
315
|
+
# viewportanchor: where on the screen that pin is placed
|
|
316
|
+
# e.g. 10%,90% means 10% from left, 90% from top
|
|
317
|
+
if "viewportanchor" in settings:
|
|
318
|
+
m = REGION_ANCHOR_PATTERN.match(settings["viewportanchor"])
|
|
319
|
+
if m:
|
|
320
|
+
viewportanchor_x = float(m.group(1))
|
|
321
|
+
viewportanchor_y = float(m.group(2))
|
|
322
|
+
|
|
323
|
+
# Calculate the top-left corner (origin) of the region box.
|
|
324
|
+
# Each line is ~5.33% of viewport height (LINE_HEIGHT_VH).
|
|
325
|
+
height = lines * LINE_HEIGHT_VH
|
|
326
|
+
# The origin is where the viewport anchor is, offset back by how far
|
|
327
|
+
# the region anchor is into the box (as a fraction of box dimensions).
|
|
328
|
+
origin_x = viewportanchor_x - (regionanchor_x / 100.0 * width)
|
|
329
|
+
origin_y = viewportanchor_y - (regionanchor_y / 100.0 * height)
|
|
330
|
+
|
|
331
|
+
origin = Point(
|
|
332
|
+
Size(origin_x, UnitEnum.PERCENT),
|
|
333
|
+
Size(origin_y, UnitEnum.PERCENT),
|
|
334
|
+
)
|
|
335
|
+
extent = Stretch(
|
|
336
|
+
Size(width, UnitEnum.PERCENT),
|
|
337
|
+
Size(height, UnitEnum.PERCENT),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return Layout(
|
|
341
|
+
origin=origin,
|
|
342
|
+
extent=extent
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def _extract_region_id(cue_settings):
|
|
347
|
+
"""Extract region id from cue settings string, if present."""
|
|
348
|
+
for setting in cue_settings.split():
|
|
349
|
+
if setting.startswith("region:"):
|
|
350
|
+
return setting[7:]
|
|
351
|
+
return None
|
|
352
|
+
|
|
192
353
|
|
|
193
354
|
class WebVTTWriter(BaseWriter):
|
|
194
355
|
HEADER = "WEBVTT\n\n"
|
|
195
|
-
global_layout = None
|
|
196
|
-
video_width = None
|
|
197
|
-
video_height = None
|
|
198
356
|
|
|
199
357
|
def write(self, caption_set, lang=None):
|
|
200
358
|
"""
|
|
@@ -347,8 +505,7 @@ class WebVTTWriter(BaseWriter):
|
|
|
347
505
|
# Since there is no padding in WebVTT, the left padding is
|
|
348
506
|
# added to the total left offset (if it is defined and not
|
|
349
507
|
# relative),
|
|
350
|
-
|
|
351
|
-
left_offset += layout.padding.start
|
|
508
|
+
left_offset += layout.padding.start
|
|
352
509
|
# and removed from the total cue width
|
|
353
510
|
if cue_width:
|
|
354
511
|
cue_width -= layout.padding.start
|
|
@@ -696,10 +696,10 @@ class TestInterpretableNodeCreator:
|
|
|
696
696
|
node_creator.interpret_command("9429")
|
|
697
697
|
self.check_closing_italics_closing_on_style_change(node_creator)
|
|
698
698
|
|
|
699
|
-
def
|
|
699
|
+
def test_remove_noop_off_on_italics(self):
|
|
700
700
|
from pycaption.scc.specialized_collections import (
|
|
701
701
|
_InstructionNode,
|
|
702
|
-
|
|
702
|
+
_remove_noop_off_on_italics,
|
|
703
703
|
)
|
|
704
704
|
|
|
705
705
|
position_tracker = DefaultProvidingPositionTracker().default
|
|
@@ -719,7 +719,7 @@ class TestInterpretableNodeCreator:
|
|
|
719
719
|
assert node_creator._collection[-2].sets_italics_off()
|
|
720
720
|
assert node_creator._collection[-1].sets_italics_on()
|
|
721
721
|
|
|
722
|
-
new_collection =
|
|
722
|
+
new_collection = _remove_noop_off_on_italics(node_creator._collection)
|
|
723
723
|
|
|
724
724
|
# should eliminate italic tags, keep only the text node
|
|
725
725
|
assert len(new_collection) == 1
|
|
@@ -739,7 +739,7 @@ class TestInterpretableNodeCreator:
|
|
|
739
739
|
assert node_creator._collection[-2].is_text_node()
|
|
740
740
|
assert node_creator._collection[-1].sets_italics_on()
|
|
741
741
|
|
|
742
|
-
new_collection =
|
|
742
|
+
new_collection = _remove_noop_off_on_italics(node_creator._collection)
|
|
743
743
|
# should not eliminate any node
|
|
744
744
|
assert new_collection[-3].sets_italics_off()
|
|
745
745
|
assert new_collection[-2].is_text_node()
|
|
@@ -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
|
|
@@ -66,7 +66,7 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
66
66
|
assert cue.end == 18737000
|
|
67
67
|
|
|
68
68
|
def test_webvtt_cue_components_removed_from_text(self):
|
|
69
|
-
result = self.reader.
|
|
69
|
+
result = self.reader._decode(
|
|
70
70
|
"<c vIntro><b>Wikipedia</b> is a great adventure. <i>It may have "
|
|
71
71
|
"its shortcomings</i>, but it is<u> the largest</u> collective "
|
|
72
72
|
"knowledge construction endevour</c> <ruby>base text <rt>"
|
|
@@ -88,8 +88,7 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
88
88
|
# todo: same assert w/ different arguments -> this can be parametrized;
|
|
89
89
|
with pytest.raises(CaptionReadError):
|
|
90
90
|
WebVTTReader(ignore_timing_errors=False).read(
|
|
91
|
-
"\n" "00:00:20.000 --> 00:00:10.000\n"
|
|
92
|
-
"foo bar baz"
|
|
91
|
+
"\n" "00:00:20.000 --> 00:00:10.000\n" "foo bar baz"
|
|
93
92
|
)
|
|
94
93
|
|
|
95
94
|
with pytest.raises(CaptionReadError):
|
|
@@ -111,15 +110,13 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
111
110
|
# Even if timing errors are ignored, this has to raise an exception
|
|
112
111
|
with pytest.raises(CaptionReadSyntaxError):
|
|
113
112
|
WebVTTReader().read(
|
|
114
|
-
"\nNOTE invalid cue stamp\n"
|
|
115
|
-
"00:00:20.000 --> \nfoo bar baz\n"
|
|
113
|
+
"\nNOTE invalid cue stamp\n" "00:00:20.000 --> \nfoo bar baz\n"
|
|
116
114
|
)
|
|
117
115
|
|
|
118
116
|
# And this too
|
|
119
117
|
with pytest.raises(CaptionReadSyntaxError):
|
|
120
118
|
WebVTTReader().read(
|
|
121
|
-
"\n00:00:20,000 --> 00:00:22,000\n"
|
|
122
|
-
"Note the comma instead of point.\n"
|
|
119
|
+
"\n00:00:20,000 --> 00:00:22,000\n" "Note the comma instead of point.\n"
|
|
123
120
|
)
|
|
124
121
|
|
|
125
122
|
# todo: at this point it can be split into 2 separate tests
|
|
@@ -147,8 +144,7 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
147
144
|
def test_invalid_files(self):
|
|
148
145
|
with pytest.raises(CaptionReadError):
|
|
149
146
|
WebVTTReader(ignore_timing_errors=False).read(
|
|
150
|
-
"00:00:20.000 --> 00:00:10.000\n"
|
|
151
|
-
"Start time is greater than end time."
|
|
147
|
+
"00:00:20.000 --> 00:00:10.000\n" "Start time is greater than end time."
|
|
152
148
|
)
|
|
153
149
|
|
|
154
150
|
with pytest.raises(CaptionReadError):
|
|
@@ -181,9 +177,7 @@ class TestWebVTTWriter:
|
|
|
181
177
|
assert sample_webvtt_double_br == results
|
|
182
178
|
|
|
183
179
|
def test_break_node_positioning_is_ignored(
|
|
184
|
-
self,
|
|
185
|
-
webvtt_from_dfxp_with_conflicting_align,
|
|
186
|
-
dfxp_style_region_align_conflict
|
|
180
|
+
self, webvtt_from_dfxp_with_conflicting_align, dfxp_style_region_align_conflict
|
|
187
181
|
):
|
|
188
182
|
caption_set = DFXPReader().read(dfxp_style_region_align_conflict)
|
|
189
183
|
results = WebVTTWriter().write(caption_set)
|
|
@@ -202,3 +196,183 @@ class TestWebVTTWriter:
|
|
|
202
196
|
assert sample_webvtt_multi_lang_de == results
|
|
203
197
|
results = WebVTTWriter().write(caption_set, "en-US")
|
|
204
198
|
assert sample_webvtt_multi_lang_en == results
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TestWebVTTRegionParsing:
|
|
202
|
+
def setup_method(self):
|
|
203
|
+
self.reader = WebVTTReader()
|
|
204
|
+
|
|
205
|
+
def test_region_block_parsed_into_layout(self):
|
|
206
|
+
vtt = (
|
|
207
|
+
"WEBVTT\n\n"
|
|
208
|
+
"REGION\n"
|
|
209
|
+
"id:subtitle_area\n"
|
|
210
|
+
"width:50%\n"
|
|
211
|
+
"lines:3\n"
|
|
212
|
+
"regionanchor:0%,100%\n"
|
|
213
|
+
"viewportanchor:10%,90%\n"
|
|
214
|
+
"scroll:up\n\n"
|
|
215
|
+
"00:00:01.000 --> 00:00:03.000 region:subtitle_area\n"
|
|
216
|
+
"Hello world\n"
|
|
217
|
+
)
|
|
218
|
+
captions = self.reader.read(vtt)
|
|
219
|
+
cue = captions.get_captions("en-US")[0]
|
|
220
|
+
assert cue.layout_info is not None
|
|
221
|
+
assert cue.layout_info.origin is not None
|
|
222
|
+
assert cue.layout_info.extent is not None
|
|
223
|
+
|
|
224
|
+
def test_region_origin_calculation(self):
|
|
225
|
+
"""origin_x = viewportanchor_x - (regionanchor_x / 100 * width)
|
|
226
|
+
origin_y = viewportanchor_y - (regionanchor_y / 100 * height)
|
|
227
|
+
height = lines * 5.33
|
|
228
|
+
"""
|
|
229
|
+
vtt = (
|
|
230
|
+
"WEBVTT\n\n"
|
|
231
|
+
"REGION\n"
|
|
232
|
+
"id:box\n"
|
|
233
|
+
"width:50%\n"
|
|
234
|
+
"lines:3\n"
|
|
235
|
+
"regionanchor:0%,100%\n"
|
|
236
|
+
"viewportanchor:10%,90%\n\n"
|
|
237
|
+
"00:00:01.000 --> 00:00:03.000 region:box\n"
|
|
238
|
+
"Test\n"
|
|
239
|
+
)
|
|
240
|
+
captions = self.reader.read(vtt)
|
|
241
|
+
cue = captions.get_captions("en-US")[0]
|
|
242
|
+
layout = cue.layout_info
|
|
243
|
+
# origin_x = 10 - (0/100 * 50) = 10.0
|
|
244
|
+
assert layout.origin.x.value == pytest.approx(10.0)
|
|
245
|
+
# height = 3 * 5.33 = 15.99
|
|
246
|
+
# origin_y = 90 - (100/100 * 15.99) = 74.01
|
|
247
|
+
assert layout.origin.y.value == pytest.approx(74.01)
|
|
248
|
+
# extent = width=50%, height=15.99%
|
|
249
|
+
assert layout.extent.horizontal.value == pytest.approx(50.0)
|
|
250
|
+
assert layout.extent.vertical.value == pytest.approx(15.99)
|
|
251
|
+
|
|
252
|
+
def test_region_defaults(self):
|
|
253
|
+
"""width=100%, lines=3, regionanchor=0%,100%, viewportanchor=0%,100%"""
|
|
254
|
+
vtt = (
|
|
255
|
+
"WEBVTT\n\n"
|
|
256
|
+
"REGION\n"
|
|
257
|
+
"id:minimal\n\n"
|
|
258
|
+
"00:00:01.000 --> 00:00:03.000 region:minimal\n"
|
|
259
|
+
"Test\n"
|
|
260
|
+
)
|
|
261
|
+
captions = self.reader.read(vtt)
|
|
262
|
+
cue = captions.get_captions("en-US")[0]
|
|
263
|
+
layout = cue.layout_info
|
|
264
|
+
# width=100, lines=3, height=15.99
|
|
265
|
+
# regionanchor=0,100 viewportanchor=0,100
|
|
266
|
+
# origin_x = 0 - (0/100 * 100) = 0
|
|
267
|
+
# origin_y = 100 - (100/100 * 15.99) = 84.01
|
|
268
|
+
assert layout.origin.x.value == pytest.approx(0.0)
|
|
269
|
+
assert layout.origin.y.value == pytest.approx(84.01)
|
|
270
|
+
assert layout.extent.horizontal.value == pytest.approx(100.0)
|
|
271
|
+
assert layout.extent.vertical.value == pytest.approx(15.99)
|
|
272
|
+
|
|
273
|
+
def test_region_webvtt_positioning_passthrough(self):
|
|
274
|
+
"""VTT->VTT round-trip: cue settings string preserved."""
|
|
275
|
+
vtt = (
|
|
276
|
+
"WEBVTT\n\n"
|
|
277
|
+
"REGION\n"
|
|
278
|
+
"id:r1\n"
|
|
279
|
+
"width:50%\n\n"
|
|
280
|
+
"00:00:01.000 --> 00:00:03.000 region:r1\n"
|
|
281
|
+
"Hello\n"
|
|
282
|
+
)
|
|
283
|
+
captions = self.reader.read(vtt)
|
|
284
|
+
cue = captions.get_captions("en-US")[0]
|
|
285
|
+
assert cue.layout_info.webvtt_positioning == "region:r1"
|
|
286
|
+
|
|
287
|
+
def test_invalid_region_reference_ignored(self):
|
|
288
|
+
vtt = (
|
|
289
|
+
"WEBVTT\n\n" "00:00:01.000 --> 00:00:03.000 region:nonexistent\n" "Hello\n"
|
|
290
|
+
)
|
|
291
|
+
captions = self.reader.read(vtt)
|
|
292
|
+
cue = captions.get_captions("en-US")[0]
|
|
293
|
+
# Falls back to raw positioning passthrough
|
|
294
|
+
assert cue.layout_info.webvtt_positioning == "region:nonexistent"
|
|
295
|
+
assert cue.layout_info.origin is None
|
|
296
|
+
|
|
297
|
+
def test_duplicate_region_id_first_wins(self):
|
|
298
|
+
vtt = (
|
|
299
|
+
"WEBVTT\n\n"
|
|
300
|
+
"REGION\n"
|
|
301
|
+
"id:dup\n"
|
|
302
|
+
"width:40%\n\n"
|
|
303
|
+
"REGION\n"
|
|
304
|
+
"id:dup\n"
|
|
305
|
+
"width:80%\n\n"
|
|
306
|
+
"00:00:01.000 --> 00:00:03.000 region:dup\n"
|
|
307
|
+
"Test\n"
|
|
308
|
+
)
|
|
309
|
+
captions = self.reader.read(vtt)
|
|
310
|
+
cue = captions.get_captions("en-US")[0]
|
|
311
|
+
assert cue.layout_info.extent.horizontal.value == pytest.approx(40.0)
|
|
312
|
+
|
|
313
|
+
def test_multiple_regions(self):
|
|
314
|
+
vtt = (
|
|
315
|
+
"WEBVTT\n\n"
|
|
316
|
+
"REGION\n"
|
|
317
|
+
"id:top\n"
|
|
318
|
+
"width:100%\n"
|
|
319
|
+
"lines:2\n"
|
|
320
|
+
"viewportanchor:0%,10%\n"
|
|
321
|
+
"regionanchor:0%,0%\n\n"
|
|
322
|
+
"REGION\n"
|
|
323
|
+
"id:bottom\n"
|
|
324
|
+
"width:100%\n"
|
|
325
|
+
"lines:2\n"
|
|
326
|
+
"viewportanchor:0%,90%\n"
|
|
327
|
+
"regionanchor:0%,0%\n\n"
|
|
328
|
+
"00:00:01.000 --> 00:00:03.000 region:top\n"
|
|
329
|
+
"Top caption\n\n"
|
|
330
|
+
"00:00:01.000 --> 00:00:03.000 region:bottom\n"
|
|
331
|
+
"Bottom caption\n"
|
|
332
|
+
)
|
|
333
|
+
captions = self.reader.read(vtt)
|
|
334
|
+
cues = captions.get_captions("en-US")
|
|
335
|
+
# top: origin_y = 10 - (0/100 * 10.66) = 10.0
|
|
336
|
+
assert cues[0].layout_info.origin.y.value == pytest.approx(10.0)
|
|
337
|
+
# bottom: origin_y = 90 - (0/100 * 10.66) = 90.0
|
|
338
|
+
assert cues[1].layout_info.origin.y.value == pytest.approx(90.0)
|
|
339
|
+
|
|
340
|
+
def test_region_without_id_ignored(self):
|
|
341
|
+
vtt = (
|
|
342
|
+
"WEBVTT\n\n"
|
|
343
|
+
"REGION\n"
|
|
344
|
+
"width:50%\n"
|
|
345
|
+
"lines:3\n\n"
|
|
346
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
347
|
+
"No region reference\n"
|
|
348
|
+
)
|
|
349
|
+
captions = self.reader.read(vtt)
|
|
350
|
+
cue = captions.get_captions("en-US")[0]
|
|
351
|
+
assert cue.layout_info is None
|
|
352
|
+
|
|
353
|
+
def test_duplicate_setting_in_region_first_wins(self):
|
|
354
|
+
vtt = (
|
|
355
|
+
"WEBVTT\n\n"
|
|
356
|
+
"REGION\n"
|
|
357
|
+
"id:r1\n"
|
|
358
|
+
"width:40%\n"
|
|
359
|
+
"width:80%\n\n"
|
|
360
|
+
"00:00:01.000 --> 00:00:03.000 region:r1\n"
|
|
361
|
+
"Test\n"
|
|
362
|
+
)
|
|
363
|
+
captions = self.reader.read(vtt)
|
|
364
|
+
cue = captions.get_captions("en-US")[0]
|
|
365
|
+
assert cue.layout_info.extent.horizontal.value == pytest.approx(40.0)
|
|
366
|
+
|
|
367
|
+
def test_cue_without_region_unaffected(self):
|
|
368
|
+
vtt = (
|
|
369
|
+
"WEBVTT\n\n"
|
|
370
|
+
"REGION\n"
|
|
371
|
+
"id:r1\n"
|
|
372
|
+
"width:50%\n\n"
|
|
373
|
+
"00:00:01.000 --> 00:00:03.000\n"
|
|
374
|
+
"No region\n"
|
|
375
|
+
)
|
|
376
|
+
captions = self.reader.read(vtt)
|
|
377
|
+
cue = captions.get_captions("en-US")[0]
|
|
378
|
+
assert cue.layout_info is None
|
|
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
|