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