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.
Files changed (58) hide show
  1. {pycaption-2.2.24/pycaption.egg-info → pycaption-2.2.25}/PKG-INFO +1 -1
  2. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/__init__.py +100 -22
  3. {pycaption-2.2.24 → pycaption-2.2.25/pycaption.egg-info}/PKG-INFO +1 -1
  4. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption.egg-info/SOURCES.txt +1 -0
  5. {pycaption-2.2.24 → pycaption-2.2.25}/setup.py +1 -1
  6. pycaption-2.2.25/tests/test_scc_writer.py +255 -0
  7. {pycaption-2.2.24 → pycaption-2.2.25}/LICENSE +0 -0
  8. {pycaption-2.2.24 → pycaption-2.2.25}/MANIFEST.in +0 -0
  9. {pycaption-2.2.24 → pycaption-2.2.25}/README.rst +0 -0
  10. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/__init__.py +0 -0
  11. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/base.py +0 -0
  12. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/dfxp/__init__.py +0 -0
  13. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/dfxp/base.py +0 -0
  14. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/dfxp/extras.py +0 -0
  15. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/exceptions.py +0 -0
  16. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/geometry.py +0 -0
  17. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/microdvd.py +0 -0
  18. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/sami.py +0 -0
  19. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/constants.py +0 -0
  20. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/specialized_collections.py +0 -0
  21. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/state_machines.py +0 -0
  22. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/scc/translator.py +0 -0
  23. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/srt.py +0 -0
  24. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/transcript.py +0 -0
  25. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/utils.py +0 -0
  26. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption/webvtt.py +0 -0
  27. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption.egg-info/dependency_links.txt +0 -0
  28. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption.egg-info/requires.txt +0 -0
  29. {pycaption-2.2.24 → pycaption-2.2.25}/pycaption.egg-info/top_level.txt +0 -0
  30. {pycaption-2.2.24 → pycaption-2.2.25}/setup.cfg +0 -0
  31. {pycaption-2.2.24 → pycaption-2.2.25}/tests/__init__.py +0 -0
  32. {pycaption-2.2.24 → pycaption-2.2.25}/tests/conftest.py +0 -0
  33. {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/__init__.py +0 -0
  34. {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/dfxp.py +0 -0
  35. {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/microdvd.py +0 -0
  36. {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/sami.py +0 -0
  37. {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/scc.py +0 -0
  38. {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/srt.py +0 -0
  39. {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/translated_scc.py +0 -0
  40. {pycaption-2.2.24 → pycaption-2.2.25}/tests/fixtures/webvtt.py +0 -0
  41. {pycaption-2.2.24 → pycaption-2.2.25}/tests/mixins.py +0 -0
  42. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_base.py +0 -0
  43. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_dfxp.py +0 -0
  44. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_dfxp_conversion.py +0 -0
  45. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_dfxp_extras.py +0 -0
  46. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_functions.py +0 -0
  47. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_geometry.py +0 -0
  48. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_microdvd.py +0 -0
  49. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_microdvd_conversion.py +0 -0
  50. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_sami.py +0 -0
  51. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_sami_conversion.py +0 -0
  52. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_scc.py +0 -0
  53. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_scc_conversion.py +0 -0
  54. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_scc_translator.py +0 -0
  55. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_srt.py +0 -0
  56. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_srt_conversion.py +0 -0
  57. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_webvtt.py +0 -0
  58. {pycaption-2.2.24 → pycaption-2.2.25}/tests/test_webvtt_conversion.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.2.24
3
+ Version: 2.2.25
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -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 (next_idx < len(word_list)
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 self.buffer_dict.active_key in ("paint", "roll") \
500
- and not self.buffer.is_empty():
501
- self.caption_stash.create_and_store(
502
- self.buffer, self.time, edm_time)
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) / 5 + 8
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 index > 0:
596
- previous_code, previous_start, previous_end = codes[index - 1]
597
- if code_start < previous_start:
598
- code_start = previous_start
599
- if previous_end + 3 * MICROSECONDS_PER_CODEWORD >= code_start:
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
- codes[index] = (code, code_start, end)
623
+ codes[index] = (code, code_start, end)
602
624
 
603
625
  # PASS 3:
604
- # Write captions.
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
- output += f"{self._format_timestamp(start)}\t"
607
- output += "94ae 94ae 9420 9420 "
608
- output += code
609
- output += "942c 942c 942f 942f\n\n"
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 _format_timestamp(microseconds):
673
- seconds_float = microseconds / 1000.0 / 1000.0
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"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycaption
3
- Version: 2.2.24
3
+ Version: 2.2.25
4
4
  Summary: Closed caption converter
5
5
  Author: Joe Norton
6
6
  Author-email: joey@nortoncrew.com
@@ -41,6 +41,7 @@ tests/test_sami_conversion.py
41
41
  tests/test_scc.py
42
42
  tests/test_scc_conversion.py
43
43
  tests/test_scc_translator.py
44
+ tests/test_scc_writer.py
44
45
  tests/test_srt.py
45
46
  tests/test_srt_conversion.py
46
47
  tests/test_webvtt.py
@@ -20,7 +20,7 @@ transcript_dependencies = ["nltk==3.9.4"]
20
20
 
21
21
  setup(
22
22
  name="pycaption",
23
- version="2.2.24",
23
+ version="2.2.25",
24
24
  description="Closed caption converter",
25
25
  long_description=open(README_PATH).read(),
26
26
  author="Joe Norton",
@@ -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