pycaption 2.3.13__tar.gz → 2.3.14__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.3.13/pycaption.egg-info → pycaption-2.3.14}/PKG-INFO +1 -1
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/writer.py +65 -59
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/webvtt/reader.py +32 -24
- {pycaption-2.3.13 → pycaption-2.3.14/pycaption.egg-info}/PKG-INFO +1 -1
- {pycaption-2.3.13 → pycaption-2.3.14}/setup.py +1 -1
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/conftest.py +12 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/dfxp.py +14 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/webvtt.py +144 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/mixins.py +19 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_sami.py +122 -1
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_sami_conversion.py +34 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_webvtt.py +177 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_webvtt_conversion.py +54 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/LICENSE +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/MANIFEST.in +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/README.rst +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/__init__.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/base.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/__init__.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/constants.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/extras.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/reader.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/dfxp/writer.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/exceptions.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/geometry.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/microdvd.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/__init__.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/constants.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/parser.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/sami/reader.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/__init__.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/constants.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/reader.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/specialized_collections.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/state_machines.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/translator.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/scc/writer.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/srt.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/transcript.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/utils.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/webvtt/__init__.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/webvtt/constants.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption/webvtt/writer.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption.egg-info/SOURCES.txt +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption.egg-info/dependency_links.txt +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption.egg-info/requires.txt +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/pycaption.egg-info/top_level.txt +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/setup.cfg +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/__init__.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/__init__.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/microdvd.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/sami.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/scc.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/srt.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/fixtures/translated_scc.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_base.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_bytes_input.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_dfxp.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_dfxp_conversion.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_dfxp_extras.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_double_encoding.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_functions.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_geometry.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_microdvd.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_microdvd_conversion.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_scc.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_scc_conversion.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_scc_translator.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_scc_writer.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_srt.py +0 -0
- {pycaption-2.3.13 → pycaption-2.3.14}/tests/test_srt_conversion.py +0 -0
|
@@ -37,7 +37,6 @@ class SAMIWriter(BaseWriter):
|
|
|
37
37
|
def __init__(self, *args, **kwargs):
|
|
38
38
|
super().__init__(*args, **kwargs)
|
|
39
39
|
self._span_stack = []
|
|
40
|
-
self.last_time = None
|
|
41
40
|
self._promote_center = False
|
|
42
41
|
|
|
43
42
|
def write(self, caption_set, **kwargs):
|
|
@@ -51,13 +50,8 @@ class SAMIWriter(BaseWriter):
|
|
|
51
50
|
caption_set.layout_info
|
|
52
51
|
)
|
|
53
52
|
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
timelines = {}
|
|
56
54
|
for lang in caption_set.get_languages():
|
|
57
|
-
self.last_time = None
|
|
58
|
-
if primary is None:
|
|
59
|
-
primary = lang
|
|
60
|
-
|
|
61
55
|
caption_set.set_layout_info(
|
|
62
56
|
lang,
|
|
63
57
|
self._relativize_and_fit_to_screen(caption_set.get_layout_info(lang)),
|
|
@@ -72,12 +66,11 @@ class SAMIWriter(BaseWriter):
|
|
|
72
66
|
node.layout_info = self._relativize_and_fit_to_screen(
|
|
73
67
|
node.layout_info
|
|
74
68
|
)
|
|
75
|
-
|
|
69
|
+
cues = self._build_timeline(captions)
|
|
70
|
+
if cues:
|
|
71
|
+
timelines[lang] = cues
|
|
76
72
|
|
|
77
|
-
|
|
78
|
-
sami = self._recreate_blank_tag(
|
|
79
|
-
sami, captions[-1], lang, primary, caption_set
|
|
80
|
-
)
|
|
73
|
+
self._recreate_syncs(sami, timelines, caption_set)
|
|
81
74
|
|
|
82
75
|
stylesheet = self._recreate_stylesheet(caption_set)
|
|
83
76
|
sami.find("style").append(stylesheet)
|
|
@@ -86,17 +79,64 @@ class SAMIWriter(BaseWriter):
|
|
|
86
79
|
caption_content = "\n".join(a[1:])
|
|
87
80
|
return caption_content
|
|
88
81
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if self.last_time and time != self.last_time:
|
|
94
|
-
sami = self._recreate_blank_tag(sami, caption, lang, primary, captions)
|
|
95
|
-
|
|
96
|
-
self.last_time = int(caption.end // 1000)
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _build_timeline(captions):
|
|
84
|
+
"""Return (start_ms, end_ms, caption) cues sorted by start.
|
|
97
85
|
|
|
98
|
-
|
|
86
|
+
Zero-length cues are never on screen, so they are dropped.
|
|
87
|
+
"""
|
|
88
|
+
cues = []
|
|
89
|
+
for caption in captions:
|
|
90
|
+
start = int(caption.start // 1000)
|
|
91
|
+
end = int(caption.end // 1000)
|
|
92
|
+
if end > start:
|
|
93
|
+
cues.append((start, end, caption))
|
|
94
|
+
cues.sort(key=lambda cue: cue[0])
|
|
95
|
+
return cues
|
|
96
|
+
|
|
97
|
+
def _recreate_syncs(self, sami, timelines, captions):
|
|
98
|
+
"""Append one <sync> per time at which any language's visible cues change.
|
|
99
|
+
|
|
100
|
+
Overlapping cues of a language are merged: a <sync> repeats every cue
|
|
101
|
+
of that language still on screen, so start times never decrease and
|
|
102
|
+
no cue is cleared early.
|
|
103
|
+
"""
|
|
104
|
+
times = sorted(
|
|
105
|
+
{time for cues in timelines.values() for cue in cues for time in cue[:2]}
|
|
106
|
+
)
|
|
107
|
+
shown = {lang: [] for lang in timelines}
|
|
108
|
+
pending = {lang: 0 for lang in timelines}
|
|
109
|
+
|
|
110
|
+
for time in times:
|
|
111
|
+
sync = None
|
|
112
|
+
for lang, cues in timelines.items():
|
|
113
|
+
active = [i for i in shown[lang] if cues[i][1] > time]
|
|
114
|
+
while pending[lang] < len(cues) and cues[pending[lang]][0] <= time:
|
|
115
|
+
active.append(pending[lang])
|
|
116
|
+
pending[lang] += 1
|
|
117
|
+
if active == shown[lang]:
|
|
118
|
+
continue
|
|
119
|
+
shown[lang] = active
|
|
120
|
+
|
|
121
|
+
if sync is None:
|
|
122
|
+
sync = sami.new_tag("sync", start=time)
|
|
123
|
+
sami.body.append(sync)
|
|
124
|
+
|
|
125
|
+
if active:
|
|
126
|
+
for i in active:
|
|
127
|
+
sync.append(
|
|
128
|
+
self._recreate_p_tag(cues[i][2], sami, lang, captions)
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
# The blank takes the class of the next cue, or of the
|
|
132
|
+
# last cue once the language has no cues left.
|
|
133
|
+
upcoming = cues[min(pending[lang], len(cues) - 1)][2]
|
|
134
|
+
sync.append(
|
|
135
|
+
self._recreate_blank_tag(upcoming, sami, lang, captions)
|
|
136
|
+
)
|
|
99
137
|
|
|
138
|
+
def _recreate_p_tag(self, caption, sami, lang, captions):
|
|
139
|
+
"""Build a <p> for a single caption with styling and alignment."""
|
|
100
140
|
p = sami.new_tag("p")
|
|
101
141
|
|
|
102
142
|
p_style = self._build_p_style(caption)
|
|
@@ -107,9 +147,7 @@ class SAMIWriter(BaseWriter):
|
|
|
107
147
|
p["class"] = self._recreate_p_lang(caption, lang, captions)
|
|
108
148
|
p.string = encode_leading_nbsp(self._recreate_text(caption.nodes))
|
|
109
149
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return sami
|
|
150
|
+
return p
|
|
113
151
|
|
|
114
152
|
def _build_p_style(self, caption):
|
|
115
153
|
"""Build the inline CSS style string for a <p> element."""
|
|
@@ -158,45 +196,13 @@ class SAMIWriter(BaseWriter):
|
|
|
158
196
|
|
|
159
197
|
return HORIZONTAL_ALIGNMENT_MAP.get(h)
|
|
160
198
|
|
|
161
|
-
def
|
|
162
|
-
"""
|
|
163
|
-
if lang == primary:
|
|
164
|
-
sync = sami.new_tag("sync", start=time)
|
|
165
|
-
sami.body.append(sync)
|
|
166
|
-
else:
|
|
167
|
-
sync = sami.find("sync", start=time)
|
|
168
|
-
if sync is None:
|
|
169
|
-
sami, sync = self._find_closest_sync(sami, time)
|
|
170
|
-
|
|
171
|
-
return sami, sync
|
|
172
|
-
|
|
173
|
-
@staticmethod
|
|
174
|
-
def _find_closest_sync(sami, time):
|
|
175
|
-
"""Insert a new <sync> tag in chronological order among existing syncs."""
|
|
176
|
-
sync = sami.new_tag("sync", start=time)
|
|
177
|
-
|
|
178
|
-
earlier = sami.find_all("sync", start=lambda x: int(x) < time)
|
|
179
|
-
if earlier:
|
|
180
|
-
last_sync = earlier[-1]
|
|
181
|
-
last_sync.insert_after(sync)
|
|
182
|
-
else:
|
|
183
|
-
later = sami.find_all("sync", start=lambda x: int(x) > time)
|
|
184
|
-
if later:
|
|
185
|
-
last_sync = later[0]
|
|
186
|
-
last_sync.insert_before(sync)
|
|
187
|
-
return sami, sync
|
|
188
|
-
|
|
189
|
-
def _recreate_blank_tag(self, sami, caption, lang, primary, captions):
|
|
190
|
-
"""Insert an paragraph to clear the previous caption at its end time."""
|
|
191
|
-
sami, sync = self._recreate_sync(sami, lang, primary, self.last_time)
|
|
192
|
-
|
|
199
|
+
def _recreate_blank_tag(self, caption, sami, lang, captions):
|
|
200
|
+
"""Build an <p> that clears the language's previous caption."""
|
|
193
201
|
p = sami.new_tag("p")
|
|
194
202
|
p["class"] = self._recreate_p_lang(caption, lang, captions)
|
|
195
203
|
p.string = " "
|
|
196
204
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return sami
|
|
205
|
+
return p
|
|
200
206
|
|
|
201
207
|
@staticmethod
|
|
202
208
|
def _recreate_p_lang(caption, lang, captions):
|
|
@@ -76,6 +76,7 @@ class _ParseState:
|
|
|
76
76
|
"open_tags",
|
|
77
77
|
"layout_info",
|
|
78
78
|
"found_timing",
|
|
79
|
+
"drop_cue",
|
|
79
80
|
"in_note_block",
|
|
80
81
|
"in_style_block",
|
|
81
82
|
"in_region_block",
|
|
@@ -90,6 +91,7 @@ class _ParseState:
|
|
|
90
91
|
self.open_tags = []
|
|
91
92
|
self.layout_info = None
|
|
92
93
|
self.found_timing = False
|
|
94
|
+
self.drop_cue = False
|
|
93
95
|
self.in_note_block = False
|
|
94
96
|
self.in_style_block = False
|
|
95
97
|
self.in_region_block = False
|
|
@@ -114,7 +116,9 @@ class WebVTTReader(BaseReader):
|
|
|
114
116
|
:param ignore_timing_errors: When False, raises on non-monotonic
|
|
115
117
|
or inverted timestamps. Default True for lenient parsing.
|
|
116
118
|
:param time_shift_milliseconds: Offset applied to all timestamps
|
|
117
|
-
(positive = shift forward, negative = shift backward).
|
|
119
|
+
(positive = shift forward, negative = shift backward). After a
|
|
120
|
+
negative shift, a cue crossing 0 is clamped to start at 0 and a
|
|
121
|
+
cue ending at or before 0 is dropped.
|
|
118
122
|
"""
|
|
119
123
|
super().__init__(*args, **kwargs)
|
|
120
124
|
self.ignore_timing_errors = ignore_timing_errors
|
|
@@ -219,15 +223,7 @@ class WebVTTReader(BaseReader):
|
|
|
219
223
|
|
|
220
224
|
# Flush the last cue if file doesn't end with a blank line
|
|
221
225
|
if state.nodes:
|
|
222
|
-
|
|
223
|
-
self._build_cue(
|
|
224
|
-
state.nodes,
|
|
225
|
-
state.open_tags,
|
|
226
|
-
state.start,
|
|
227
|
-
state.end,
|
|
228
|
-
state.layout_info,
|
|
229
|
-
)
|
|
230
|
-
)
|
|
226
|
+
self._finalize_cue(state, captions)
|
|
231
227
|
|
|
232
228
|
return captions
|
|
233
229
|
|
|
@@ -251,6 +247,7 @@ class WebVTTReader(BaseReader):
|
|
|
251
247
|
state.start, state.end, state.layout_info = self._read_timing(
|
|
252
248
|
line, line_index, last_start_time
|
|
253
249
|
)
|
|
250
|
+
state.drop_cue = state.start is None
|
|
254
251
|
return
|
|
255
252
|
|
|
256
253
|
if not state.found_timing and line != "":
|
|
@@ -261,12 +258,10 @@ class WebVTTReader(BaseReader):
|
|
|
261
258
|
state.pending_id = None
|
|
262
259
|
return
|
|
263
260
|
|
|
264
|
-
if line == "" and state.found_timing
|
|
261
|
+
if line == "" and state.found_timing:
|
|
265
262
|
self._finalize_cue(state, captions)
|
|
266
263
|
return
|
|
267
264
|
|
|
268
|
-
if not state.found_timing or line == "":
|
|
269
|
-
return
|
|
270
265
|
if state.nodes:
|
|
271
266
|
state.nodes.append(CaptionNode.create_break())
|
|
272
267
|
state.nodes.extend(self._parse_cue_text(line, state.open_tags))
|
|
@@ -320,18 +315,20 @@ class WebVTTReader(BaseReader):
|
|
|
320
315
|
state.seen_ids.add(cue_id)
|
|
321
316
|
|
|
322
317
|
def _finalize_cue(self, state, captions):
|
|
323
|
-
"""Close the current cue, append it to captions
|
|
324
|
-
for the next cue."""
|
|
318
|
+
"""Close the current cue, append it to captions unless it is dropped
|
|
319
|
+
or empty, and reset state for the next cue."""
|
|
325
320
|
state.found_timing = False
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
321
|
+
if not state.drop_cue and state.nodes:
|
|
322
|
+
captions.append(
|
|
323
|
+
self._build_cue(
|
|
324
|
+
state.nodes,
|
|
325
|
+
state.open_tags,
|
|
326
|
+
state.start,
|
|
327
|
+
state.end,
|
|
328
|
+
state.layout_info,
|
|
329
|
+
)
|
|
333
330
|
)
|
|
334
|
-
|
|
331
|
+
state.drop_cue = False
|
|
335
332
|
state.nodes = []
|
|
336
333
|
state.open_tags = []
|
|
337
334
|
|
|
@@ -438,7 +435,9 @@ class WebVTTReader(BaseReader):
|
|
|
438
435
|
:param line: Raw timing line, e.g.
|
|
439
436
|
"00:00:01.000 --> 00:00:05.000 line:80%"
|
|
440
437
|
:param last_start_time: Start time of previous cue (for validation).
|
|
441
|
-
:returns: Tuple of (start_us, end_us, Layout or None).
|
|
438
|
+
:returns: Tuple of (start_us, end_us, Layout or None). start_us and
|
|
439
|
+
end_us are None when a negative time shift puts the cue end at
|
|
440
|
+
or before 0, meaning the cue is dropped.
|
|
442
441
|
:raises CaptionReadSyntaxError: On unparseable timing format.
|
|
443
442
|
"""
|
|
444
443
|
m = TIMING_LINE_PATTERN.search(line)
|
|
@@ -449,6 +448,15 @@ class WebVTTReader(BaseReader):
|
|
|
449
448
|
end = self._parse_timestamp(m.group(2)) + self.time_shift_microseconds
|
|
450
449
|
cue_settings = m.group(3)
|
|
451
450
|
|
|
451
|
+
if self.time_shift_microseconds < 0:
|
|
452
|
+
if end <= 0:
|
|
453
|
+
# Shifted entirely before 0: drop. Still reject inverted
|
|
454
|
+
# timings; ordering is moot since the cue is not kept.
|
|
455
|
+
if not self.ignore_timing_errors:
|
|
456
|
+
self._validate_timings(start, end, start)
|
|
457
|
+
return None, None, None
|
|
458
|
+
start = max(start, 0)
|
|
459
|
+
|
|
452
460
|
if not self.ignore_timing_errors:
|
|
453
461
|
self._validate_timings(start, end, last_start_time)
|
|
454
462
|
|
|
@@ -29,6 +29,7 @@ from tests.fixtures.dfxp import ( # noqa: F401
|
|
|
29
29
|
sample_dfxp_multiple_regions_output,
|
|
30
30
|
sample_dfxp_only_spaces_paragraph,
|
|
31
31
|
sample_dfxp_output,
|
|
32
|
+
sample_dfxp_overlapping_cues,
|
|
32
33
|
sample_dfxp_style_tag_with_no_xml_id_input,
|
|
33
34
|
sample_dfxp_style_tag_with_no_xml_id_output,
|
|
34
35
|
sample_dfxp_syntax_error,
|
|
@@ -219,7 +220,16 @@ from tests.fixtures.webvtt import ( # noqa: F401
|
|
|
219
220
|
sample_webvtt_double_br,
|
|
220
221
|
sample_webvtt_empty,
|
|
221
222
|
sample_webvtt_empty_cue,
|
|
223
|
+
sample_webvtt_empty_cue_multiple_blanks,
|
|
224
|
+
sample_webvtt_empty_cue_only_orphan_text,
|
|
222
225
|
sample_webvtt_empty_cue_output,
|
|
226
|
+
sample_webvtt_empty_cue_then_duplicate_id,
|
|
227
|
+
sample_webvtt_empty_cue_then_id,
|
|
228
|
+
sample_webvtt_empty_cue_then_note,
|
|
229
|
+
sample_webvtt_empty_cue_then_orphan_text,
|
|
230
|
+
sample_webvtt_empty_cue_then_region,
|
|
231
|
+
sample_webvtt_empty_cue_then_style,
|
|
232
|
+
sample_webvtt_empty_last_cue,
|
|
223
233
|
sample_webvtt_from_dfxp,
|
|
224
234
|
sample_webvtt_from_dfxp_with_positioning_and_style,
|
|
225
235
|
sample_webvtt_from_dfxp_with_style,
|
|
@@ -233,11 +243,13 @@ from tests.fixtures.webvtt import ( # noqa: F401
|
|
|
233
243
|
sample_webvtt_last_cue_zero_start,
|
|
234
244
|
sample_webvtt_multi_lang_de,
|
|
235
245
|
sample_webvtt_multi_lang_en,
|
|
246
|
+
sample_webvtt_negative_shift,
|
|
236
247
|
sample_webvtt_no_blank_after_header,
|
|
237
248
|
sample_webvtt_no_header,
|
|
238
249
|
sample_webvtt_note_with_arrow,
|
|
239
250
|
sample_webvtt_numeric_entities,
|
|
240
251
|
sample_webvtt_output_long_cue,
|
|
252
|
+
sample_webvtt_overlapping_cues,
|
|
241
253
|
sample_webvtt_style_with_arrow,
|
|
242
254
|
sample_webvtt_timestamps,
|
|
243
255
|
sample_webvtt_with_bom,
|
|
@@ -1638,3 +1638,17 @@ def sample_dfxp_with_nbsp_indentation():
|
|
|
1638
1638
|
</div>
|
|
1639
1639
|
</body>
|
|
1640
1640
|
</tt>"""
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
@pytest.fixture(scope="session")
|
|
1644
|
+
def sample_dfxp_overlapping_cues():
|
|
1645
|
+
return """\
|
|
1646
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
1647
|
+
<tt xml:lang="en" xmlns="http://www.w3.org/ns/ttml">
|
|
1648
|
+
<body>
|
|
1649
|
+
<div xml:lang="en-US">
|
|
1650
|
+
<p begin="00:00:00.500" end="00:00:03.000">A</p>
|
|
1651
|
+
<p begin="00:00:01.000" end="00:00:01.800">B</p>
|
|
1652
|
+
</div>
|
|
1653
|
+
</body>
|
|
1654
|
+
</tt>"""
|
|
@@ -343,6 +343,124 @@ Transcribed by Celestials
|
|
|
343
343
|
"""
|
|
344
344
|
|
|
345
345
|
|
|
346
|
+
@pytest.fixture(scope="session")
|
|
347
|
+
def sample_webvtt_empty_cue_then_id():
|
|
348
|
+
return """WEBVTT
|
|
349
|
+
|
|
350
|
+
cue-1
|
|
351
|
+
00:00.000 --> 00:02.000
|
|
352
|
+
|
|
353
|
+
cue-2
|
|
354
|
+
00:04.000 --> 00:05.000
|
|
355
|
+
C
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@pytest.fixture(scope="session")
|
|
360
|
+
def sample_webvtt_empty_cue_then_note():
|
|
361
|
+
return """WEBVTT
|
|
362
|
+
|
|
363
|
+
00:00.000 --> 00:02.000
|
|
364
|
+
|
|
365
|
+
NOTE hello
|
|
366
|
+
|
|
367
|
+
00:04.000 --> 00:05.000
|
|
368
|
+
C
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@pytest.fixture(scope="session")
|
|
373
|
+
def sample_webvtt_empty_cue_then_style():
|
|
374
|
+
return """WEBVTT
|
|
375
|
+
|
|
376
|
+
00:00.000 --> 00:02.000
|
|
377
|
+
|
|
378
|
+
STYLE
|
|
379
|
+
::cue { color: red }
|
|
380
|
+
|
|
381
|
+
00:04.000 --> 00:05.000
|
|
382
|
+
C
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@pytest.fixture(scope="session")
|
|
387
|
+
def sample_webvtt_empty_cue_then_region():
|
|
388
|
+
return """WEBVTT
|
|
389
|
+
|
|
390
|
+
00:00.000 --> 00:02.000
|
|
391
|
+
|
|
392
|
+
REGION
|
|
393
|
+
id:r1
|
|
394
|
+
|
|
395
|
+
00:04.000 --> 00:05.000
|
|
396
|
+
C
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@pytest.fixture(scope="session")
|
|
401
|
+
def sample_webvtt_empty_cue_multiple_blanks():
|
|
402
|
+
return """WEBVTT
|
|
403
|
+
|
|
404
|
+
00:00.000 --> 00:02.000
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
cue-2
|
|
409
|
+
00:04.000 --> 00:05.000
|
|
410
|
+
C
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@pytest.fixture(scope="session")
|
|
415
|
+
def sample_webvtt_empty_last_cue():
|
|
416
|
+
return """WEBVTT
|
|
417
|
+
|
|
418
|
+
cue-1
|
|
419
|
+
00:00.000 --> 00:02.000
|
|
420
|
+
A
|
|
421
|
+
|
|
422
|
+
cue-2
|
|
423
|
+
00:03.000 --> 00:04.000
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@pytest.fixture(scope="session")
|
|
428
|
+
def sample_webvtt_empty_cue_then_duplicate_id():
|
|
429
|
+
return """WEBVTT
|
|
430
|
+
|
|
431
|
+
cue-1
|
|
432
|
+
00:00.000 --> 00:02.000
|
|
433
|
+
|
|
434
|
+
cue-1
|
|
435
|
+
00:04.000 --> 00:05.000
|
|
436
|
+
C
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@pytest.fixture(scope="session")
|
|
441
|
+
def sample_webvtt_empty_cue_then_orphan_text():
|
|
442
|
+
return """WEBVTT
|
|
443
|
+
|
|
444
|
+
00:00.000 --> 00:02.000
|
|
445
|
+
|
|
446
|
+
hello
|
|
447
|
+
world
|
|
448
|
+
|
|
449
|
+
00:04.000 --> 00:05.000
|
|
450
|
+
C
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@pytest.fixture(scope="session")
|
|
455
|
+
def sample_webvtt_empty_cue_only_orphan_text():
|
|
456
|
+
return """WEBVTT
|
|
457
|
+
|
|
458
|
+
00:00.000 --> 00:02.000
|
|
459
|
+
|
|
460
|
+
hello
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
|
|
346
464
|
@pytest.fixture(scope="session")
|
|
347
465
|
def sample_webvtt_multi_lang_en():
|
|
348
466
|
return """WEBVTT
|
|
@@ -594,3 +712,29 @@ def sample_webvtt_style_with_arrow():
|
|
|
594
712
|
@pytest.fixture(scope="session")
|
|
595
713
|
def sample_webvtt_numeric_entities():
|
|
596
714
|
return "WEBVTT\n\n" "00:00:01.000 --> 00:00:03.000\n" "© and ♫\n"
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@pytest.fixture(scope="session")
|
|
718
|
+
def sample_webvtt_negative_shift():
|
|
719
|
+
return (
|
|
720
|
+
"WEBVTT\n\n"
|
|
721
|
+
"00:00:00.500 --> 00:00:01.500\n"
|
|
722
|
+
"Straddles zero\n\n"
|
|
723
|
+
"00:00:01.000 --> 00:00:01.800\n"
|
|
724
|
+
"Fully before zero\n\n"
|
|
725
|
+
"00:00:03.000 --> 00:00:05.000\n"
|
|
726
|
+
"After zero\n"
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@pytest.fixture(scope="session")
|
|
731
|
+
def sample_webvtt_overlapping_cues():
|
|
732
|
+
return (
|
|
733
|
+
"WEBVTT\n\n"
|
|
734
|
+
"00:00:00.500 --> 00:00:01.500\n"
|
|
735
|
+
"A\n\n"
|
|
736
|
+
"00:00:01.000 --> 00:00:01.800\n"
|
|
737
|
+
"B\n\n"
|
|
738
|
+
"00:00:03.000 --> 00:00:05.000\n"
|
|
739
|
+
"C\n"
|
|
740
|
+
)
|
|
@@ -4,6 +4,7 @@ import pytest
|
|
|
4
4
|
|
|
5
5
|
from bs4 import BeautifulSoup
|
|
6
6
|
|
|
7
|
+
from pycaption import SAMIReader
|
|
7
8
|
from pycaption.exceptions import InvalidInputError
|
|
8
9
|
|
|
9
10
|
|
|
@@ -176,6 +177,24 @@ class SAMITestingMixIn:
|
|
|
176
177
|
|
|
177
178
|
assert self._strip_blanks(first_items) == self._strip_blanks(second_items)
|
|
178
179
|
|
|
180
|
+
def assert_sami_syncs_equal(self, sami, expected):
|
|
181
|
+
"""Assert the SYNCs as (start, [(class, text), ...]); "" is a blank.
|
|
182
|
+
|
|
183
|
+
Start values must strictly increase, and the output must read back.
|
|
184
|
+
"""
|
|
185
|
+
syncs = [
|
|
186
|
+
(
|
|
187
|
+
int(sync["start"]),
|
|
188
|
+
[(" ".join(p["class"]), p.text.strip()) for p in sync.find_all("p")],
|
|
189
|
+
)
|
|
190
|
+
for sync in BeautifulSoup(sami, "lxml").select("sync")
|
|
191
|
+
]
|
|
192
|
+
starts = [start for start, _ in syncs]
|
|
193
|
+
|
|
194
|
+
assert starts == sorted(set(starts))
|
|
195
|
+
assert syncs == expected
|
|
196
|
+
SAMIReader().read(sami)
|
|
197
|
+
|
|
179
198
|
|
|
180
199
|
class MicroDVDTestingMixIn:
|
|
181
200
|
"""
|
|
@@ -11,7 +11,7 @@ from pycaption import (
|
|
|
11
11
|
from pycaption.base import Caption, CaptionList, CaptionNode, CaptionSet
|
|
12
12
|
from pycaption.exceptions import CaptionReadTimingError
|
|
13
13
|
from pycaption.geometry import HorizontalAlignmentEnum, Size, UnitEnum # noqa
|
|
14
|
-
from tests.mixins import ReaderTestingMixIn
|
|
14
|
+
from tests.mixins import ReaderTestingMixIn, SAMITestingMixIn
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class TestSAMIReader(ReaderTestingMixIn):
|
|
@@ -269,3 +269,124 @@ class TestSAMIWriterNbspIndentation:
|
|
|
269
269
|
'"The Story Knife."',
|
|
270
270
|
"\xa0\xa0Part one",
|
|
271
271
|
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _captions(*cues):
|
|
275
|
+
"""Build a CaptionList from (start_ms, end_ms, text) tuples."""
|
|
276
|
+
return CaptionList(
|
|
277
|
+
[
|
|
278
|
+
Caption(start * 1000, end * 1000, [CaptionNode.create_text(text)])
|
|
279
|
+
for start, end, text in cues
|
|
280
|
+
]
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
PARTIAL_OVERLAP = [(500, 1500, "A"), (1000, 1800, "B"), (3000, 5000, "C")]
|
|
285
|
+
TOUCHING = [(500, 1000, "A"), (1000, 1800, "B")]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class TestSAMIWriterOverlappingCues(SAMITestingMixIn):
|
|
289
|
+
"""Overlapping cues are merged so SYNC start values never decrease."""
|
|
290
|
+
|
|
291
|
+
def _write(self, **languages):
|
|
292
|
+
return SAMIWriter().write(
|
|
293
|
+
CaptionSet(
|
|
294
|
+
{
|
|
295
|
+
lang.replace("_", "-"): _captions(*cues)
|
|
296
|
+
for lang, cues in languages.items()
|
|
297
|
+
}
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def test_partial_overlap(self):
|
|
302
|
+
sami = self._write(en_US=PARTIAL_OVERLAP)
|
|
303
|
+
|
|
304
|
+
self.assert_sami_syncs_equal(
|
|
305
|
+
sami,
|
|
306
|
+
[
|
|
307
|
+
(500, [("en-US", "A")]),
|
|
308
|
+
(1000, [("en-US", "A"), ("en-US", "B")]),
|
|
309
|
+
(1500, [("en-US", "B")]),
|
|
310
|
+
(1800, [("en-US", "")]),
|
|
311
|
+
(3000, [("en-US", "C")]),
|
|
312
|
+
(5000, [("en-US", "")]),
|
|
313
|
+
],
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def test_nested_overlap(self):
|
|
317
|
+
sami = self._write(en_US=[(500, 3000, "A"), (1000, 1800, "B")])
|
|
318
|
+
|
|
319
|
+
self.assert_sami_syncs_equal(
|
|
320
|
+
sami,
|
|
321
|
+
[
|
|
322
|
+
(500, [("en-US", "A")]),
|
|
323
|
+
(1000, [("en-US", "A"), ("en-US", "B")]),
|
|
324
|
+
(1800, [("en-US", "A")]),
|
|
325
|
+
(3000, [("en-US", "")]),
|
|
326
|
+
],
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def test_same_start(self):
|
|
330
|
+
sami = self._write(en_US=[(0, 500, "A"), (0, 800, "B")])
|
|
331
|
+
|
|
332
|
+
self.assert_sami_syncs_equal(
|
|
333
|
+
sami,
|
|
334
|
+
[
|
|
335
|
+
(0, [("en-US", "A"), ("en-US", "B")]),
|
|
336
|
+
(500, [("en-US", "B")]),
|
|
337
|
+
(800, [("en-US", "")]),
|
|
338
|
+
],
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def test_touching_cues_are_unchanged(self):
|
|
342
|
+
sami = self._write(en_US=TOUCHING)
|
|
343
|
+
|
|
344
|
+
self.assert_sami_syncs_equal(
|
|
345
|
+
sami,
|
|
346
|
+
[
|
|
347
|
+
(500, [("en-US", "A")]),
|
|
348
|
+
(1000, [("en-US", "B")]),
|
|
349
|
+
(1800, [("en-US", "")]),
|
|
350
|
+
],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def test_overlap_only_in_second_language(self):
|
|
354
|
+
sami = self._write(en_US=TOUCHING, de_DE=PARTIAL_OVERLAP)
|
|
355
|
+
|
|
356
|
+
self.assert_sami_syncs_equal(
|
|
357
|
+
sami,
|
|
358
|
+
[
|
|
359
|
+
(500, [("en-US", "A"), ("de-DE", "A")]),
|
|
360
|
+
(1000, [("en-US", "B"), ("de-DE", "A"), ("de-DE", "B")]),
|
|
361
|
+
(1500, [("de-DE", "B")]),
|
|
362
|
+
(1800, [("en-US", ""), ("de-DE", "")]),
|
|
363
|
+
(3000, [("de-DE", "C")]),
|
|
364
|
+
(5000, [("de-DE", "")]),
|
|
365
|
+
],
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def test_cues_out_of_input_order(self):
|
|
369
|
+
sami = self._write(en_US=[(3000, 5000, "C"), (500, 1500, "A")])
|
|
370
|
+
|
|
371
|
+
self.assert_sami_syncs_equal(
|
|
372
|
+
sami,
|
|
373
|
+
[
|
|
374
|
+
(500, [("en-US", "A")]),
|
|
375
|
+
(1500, [("en-US", "")]),
|
|
376
|
+
(3000, [("en-US", "C")]),
|
|
377
|
+
(5000, [("en-US", "")]),
|
|
378
|
+
],
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def test_zero_length_cue_is_dropped(self):
|
|
382
|
+
sami = self._write(en_US=[(0, 1000, "A"), (2000, 2000, "Z"), (3000, 4000, "B")])
|
|
383
|
+
|
|
384
|
+
self.assert_sami_syncs_equal(
|
|
385
|
+
sami,
|
|
386
|
+
[
|
|
387
|
+
(0, [("en-US", "A")]),
|
|
388
|
+
(1000, [("en-US", "")]),
|
|
389
|
+
(3000, [("en-US", "B")]),
|
|
390
|
+
(4000, [("en-US", "")]),
|
|
391
|
+
],
|
|
392
|
+
)
|
|
@@ -118,6 +118,40 @@ class TestWebVTTtoSAMI(SAMITestingMixIn):
|
|
|
118
118
|
self.assert_sami_captions_equal(sample_sami, result)
|
|
119
119
|
|
|
120
120
|
|
|
121
|
+
class TestOverlappingCuesToSAMI(SAMITestingMixIn):
|
|
122
|
+
def test_overlapping_webvtt_gives_in_order_syncs(
|
|
123
|
+
self, sample_webvtt_overlapping_cues
|
|
124
|
+
):
|
|
125
|
+
caption_set = WebVTTReader().read(sample_webvtt_overlapping_cues)
|
|
126
|
+
result = SAMIWriter().write(caption_set)
|
|
127
|
+
|
|
128
|
+
self.assert_sami_syncs_equal(
|
|
129
|
+
result,
|
|
130
|
+
[
|
|
131
|
+
(500, [("en-US", "A")]),
|
|
132
|
+
(1000, [("en-US", "A"), ("en-US", "B")]),
|
|
133
|
+
(1500, [("en-US", "B")]),
|
|
134
|
+
(1800, [("en-US", "")]),
|
|
135
|
+
(3000, [("en-US", "C")]),
|
|
136
|
+
(5000, [("en-US", "")]),
|
|
137
|
+
],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def test_overlapping_dfxp_gives_in_order_syncs(self, sample_dfxp_overlapping_cues):
|
|
141
|
+
caption_set = DFXPReader().read(sample_dfxp_overlapping_cues)
|
|
142
|
+
result = SAMIWriter().write(caption_set)
|
|
143
|
+
|
|
144
|
+
self.assert_sami_syncs_equal(
|
|
145
|
+
result,
|
|
146
|
+
[
|
|
147
|
+
(500, [("en-US", "A")]),
|
|
148
|
+
(1000, [("en-US", "A"), ("en-US", "B")]),
|
|
149
|
+
(1800, [("en-US", "A")]),
|
|
150
|
+
(3000, [("en-US", "")]),
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
121
155
|
class TestWebVTTtoSAMIStyles(SAMITestingMixIn):
|
|
122
156
|
"""Tests for WebVTT structured style/layout → SAMI conversion."""
|
|
123
157
|
|
|
@@ -79,6 +79,115 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
79
79
|
assert cue.start == 16985000
|
|
80
80
|
assert cue.end == 18737000
|
|
81
81
|
|
|
82
|
+
def test_negative_shift_clamps_cue_crossing_zero(
|
|
83
|
+
self, sample_webvtt_negative_shift
|
|
84
|
+
):
|
|
85
|
+
captions = WebVTTReader(time_shift_milliseconds=-1000).read(
|
|
86
|
+
sample_webvtt_negative_shift
|
|
87
|
+
)
|
|
88
|
+
cue = captions.get_captions("en-US")[0]
|
|
89
|
+
|
|
90
|
+
assert cue.get_text() == "Straddles zero"
|
|
91
|
+
assert cue.start == 0
|
|
92
|
+
assert cue.end == 500000
|
|
93
|
+
|
|
94
|
+
def test_negative_shift_keeps_cue_starting_exactly_at_zero(
|
|
95
|
+
self, sample_webvtt_negative_shift
|
|
96
|
+
):
|
|
97
|
+
captions = WebVTTReader(time_shift_milliseconds=-1000).read(
|
|
98
|
+
sample_webvtt_negative_shift
|
|
99
|
+
)
|
|
100
|
+
cue = captions.get_captions("en-US")[1]
|
|
101
|
+
|
|
102
|
+
assert cue.get_text() == "Fully before zero"
|
|
103
|
+
assert cue.start == 0
|
|
104
|
+
assert cue.end == 800000
|
|
105
|
+
|
|
106
|
+
def test_negative_shift_drops_cues_ending_at_or_before_zero(
|
|
107
|
+
self, sample_webvtt_negative_shift
|
|
108
|
+
):
|
|
109
|
+
# -1500: "Straddles zero" ends exactly at 0, "Fully before zero"
|
|
110
|
+
# crosses 0. -2000: both end before 0.
|
|
111
|
+
captions = WebVTTReader(time_shift_milliseconds=-1500).read(
|
|
112
|
+
sample_webvtt_negative_shift
|
|
113
|
+
)
|
|
114
|
+
assert [c.get_text() for c in captions.get_captions("en-US")] == [
|
|
115
|
+
"Fully before zero",
|
|
116
|
+
"After zero",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
captions = WebVTTReader(time_shift_milliseconds=-2000).read(
|
|
120
|
+
sample_webvtt_negative_shift
|
|
121
|
+
)
|
|
122
|
+
cues = captions.get_captions("en-US")
|
|
123
|
+
|
|
124
|
+
assert [c.get_text() for c in cues] == ["After zero"]
|
|
125
|
+
assert cues[0].start == 1000000
|
|
126
|
+
assert cues[0].end == 3000000
|
|
127
|
+
|
|
128
|
+
def test_negative_shift_leaves_cue_after_zero_unchanged(
|
|
129
|
+
self, sample_webvtt_negative_shift
|
|
130
|
+
):
|
|
131
|
+
captions = WebVTTReader(time_shift_milliseconds=-1000).read(
|
|
132
|
+
sample_webvtt_negative_shift
|
|
133
|
+
)
|
|
134
|
+
cue = captions.get_captions("en-US")[2]
|
|
135
|
+
|
|
136
|
+
assert cue.start == 2000000
|
|
137
|
+
assert cue.end == 4000000
|
|
138
|
+
|
|
139
|
+
def test_negative_shift_drops_last_cue_without_trailing_blank_line(self):
|
|
140
|
+
captions = WebVTTReader(time_shift_milliseconds=-3500).read(
|
|
141
|
+
"WEBVTT\n\n00:00:03.000 --> 00:00:04.000\nKept\n\n"
|
|
142
|
+
"00:00:03.000 --> 00:00:03.500\nDropped"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
assert [c.get_text() for c in captions.get_captions("en-US")] == ["Kept"]
|
|
146
|
+
|
|
147
|
+
def test_negative_shift_dropping_every_cue_raises_no_captions(
|
|
148
|
+
self, sample_webvtt_negative_shift
|
|
149
|
+
):
|
|
150
|
+
with pytest.raises(CaptionReadNoCaptions):
|
|
151
|
+
WebVTTReader(time_shift_milliseconds=-5000).read(
|
|
152
|
+
sample_webvtt_negative_shift
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def test_negative_shift_clamping_passes_strict_timing_validation(
|
|
156
|
+
self, sample_webvtt_negative_shift
|
|
157
|
+
):
|
|
158
|
+
captions = WebVTTReader(
|
|
159
|
+
ignore_timing_errors=False, time_shift_milliseconds=-1000
|
|
160
|
+
).read(sample_webvtt_negative_shift)
|
|
161
|
+
|
|
162
|
+
assert [c.start for c in captions.get_captions("en-US")] == [
|
|
163
|
+
0,
|
|
164
|
+
0,
|
|
165
|
+
2000000,
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
def test_negative_shift_still_rejects_inverted_dropped_cue(self):
|
|
169
|
+
with pytest.raises(CaptionReadError):
|
|
170
|
+
WebVTTReader(
|
|
171
|
+
ignore_timing_errors=False, time_shift_milliseconds=-5000
|
|
172
|
+
).read("WEBVTT\n\n00:00:02.000 --> 00:00:01.000\nInverted\n")
|
|
173
|
+
|
|
174
|
+
def test_negative_float_shift_clamps(self, sample_webvtt_negative_shift):
|
|
175
|
+
captions = WebVTTReader(time_shift_milliseconds=-1000.5).read(
|
|
176
|
+
sample_webvtt_negative_shift
|
|
177
|
+
)
|
|
178
|
+
cues = captions.get_captions("en-US")
|
|
179
|
+
|
|
180
|
+
assert cues[0].start == 0
|
|
181
|
+
assert cues[0].end == 499500
|
|
182
|
+
assert cues[1].start == 0
|
|
183
|
+
|
|
184
|
+
def test_zero_shift_keeps_cue_at_zero(self):
|
|
185
|
+
captions = WebVTTReader(time_shift_milliseconds=0).read(
|
|
186
|
+
"WEBVTT\n\n00:00:00.000 --> 00:00:00.000\nZero length\n"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
assert len(captions.get_captions("en-US")) == 1
|
|
190
|
+
|
|
82
191
|
def test_webvtt_cue_components_parsed_from_text(self):
|
|
83
192
|
nodes = self.reader._parse_cue_text(
|
|
84
193
|
"<c vIntro><b>Wikipedia</b> is a great adventure. <i>It may have "
|
|
@@ -189,6 +298,74 @@ class TestWebVTTReader(ReaderTestingMixIn):
|
|
|
189
298
|
def test_webvtt_empty_cue(self, sample_webvtt_empty_cue):
|
|
190
299
|
assert 1 == len(self.reader.read(sample_webvtt_empty_cue).get_captions("en-US"))
|
|
191
300
|
|
|
301
|
+
@pytest.mark.parametrize("ignore_timing_errors", [True, False])
|
|
302
|
+
@pytest.mark.parametrize(
|
|
303
|
+
"fixture_name",
|
|
304
|
+
[
|
|
305
|
+
"sample_webvtt_empty_cue_then_id",
|
|
306
|
+
"sample_webvtt_empty_cue_then_note",
|
|
307
|
+
"sample_webvtt_empty_cue_then_style",
|
|
308
|
+
"sample_webvtt_empty_cue_then_region",
|
|
309
|
+
"sample_webvtt_empty_cue_multiple_blanks",
|
|
310
|
+
],
|
|
311
|
+
)
|
|
312
|
+
def test_blank_line_closes_empty_cue(
|
|
313
|
+
self, request, fixture_name, ignore_timing_errors
|
|
314
|
+
):
|
|
315
|
+
captions = WebVTTReader(ignore_timing_errors=ignore_timing_errors).read(
|
|
316
|
+
request.getfixturevalue(fixture_name)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
assert [
|
|
320
|
+
(c.start, c.end, c.get_text()) for c in captions.get_captions("en-US")
|
|
321
|
+
] == [(4000000, 5000000, "C")]
|
|
322
|
+
|
|
323
|
+
def test_empty_last_cue_is_dropped(self, sample_webvtt_empty_last_cue):
|
|
324
|
+
captions = self.reader.read(sample_webvtt_empty_last_cue)
|
|
325
|
+
|
|
326
|
+
assert [
|
|
327
|
+
(c.start, c.end, c.get_text()) for c in captions.get_captions("en-US")
|
|
328
|
+
] == [(0, 2000000, "A")]
|
|
329
|
+
|
|
330
|
+
@pytest.mark.parametrize("ignore_timing_errors", [True, False])
|
|
331
|
+
def test_empty_cue_id_counts_for_duplicate_warning(
|
|
332
|
+
self, sample_webvtt_empty_cue_then_duplicate_id, ignore_timing_errors
|
|
333
|
+
):
|
|
334
|
+
reader = WebVTTReader(ignore_timing_errors=ignore_timing_errors)
|
|
335
|
+
with pytest.warns(CaptionReadWarning, match="Duplicate cue identifier 'cue-1'"):
|
|
336
|
+
captions = reader.read(sample_webvtt_empty_cue_then_duplicate_id)
|
|
337
|
+
|
|
338
|
+
assert [c.get_text() for c in captions.get_captions("en-US")] == ["C"]
|
|
339
|
+
|
|
340
|
+
def test_dropped_empty_cue_before_zero_is_closed(self):
|
|
341
|
+
captions = WebVTTReader(time_shift_milliseconds=-2000).read(
|
|
342
|
+
"WEBVTT\n\n00:00.000 --> 00:01.000\n\ncue-2\n00:03.000 --> 00:04.000\nC\n"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
assert [
|
|
346
|
+
(c.start, c.end, c.get_text()) for c in captions.get_captions("en-US")
|
|
347
|
+
] == [(1000000, 2000000, "C")]
|
|
348
|
+
|
|
349
|
+
@pytest.mark.parametrize("ignore_timing_errors", [True, False])
|
|
350
|
+
def test_orphan_text_after_empty_cue_is_ignored(
|
|
351
|
+
self, sample_webvtt_empty_cue_then_orphan_text, ignore_timing_errors
|
|
352
|
+
):
|
|
353
|
+
captions = WebVTTReader(ignore_timing_errors=ignore_timing_errors).read(
|
|
354
|
+
sample_webvtt_empty_cue_then_orphan_text
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
assert [
|
|
358
|
+
(c.start, c.end, c.get_text()) for c in captions.get_captions("en-US")
|
|
359
|
+
] == [(4000000, 5000000, "C")]
|
|
360
|
+
|
|
361
|
+
@pytest.mark.parametrize("ignore_timing_errors", [True, False])
|
|
362
|
+
def test_only_orphan_text_after_empty_cue_raises_no_captions(
|
|
363
|
+
self, sample_webvtt_empty_cue_only_orphan_text, ignore_timing_errors
|
|
364
|
+
):
|
|
365
|
+
reader = WebVTTReader(ignore_timing_errors=ignore_timing_errors)
|
|
366
|
+
with pytest.raises(CaptionReadNoCaptions):
|
|
367
|
+
reader.read(sample_webvtt_empty_cue_only_orphan_text)
|
|
368
|
+
|
|
192
369
|
|
|
193
370
|
class TestWebVTTWriter:
|
|
194
371
|
def setup_method(self):
|
|
@@ -2,6 +2,8 @@ import re
|
|
|
2
2
|
|
|
3
3
|
from xml.etree import ElementTree
|
|
4
4
|
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
5
7
|
from pycaption import (
|
|
6
8
|
DFXPReader,
|
|
7
9
|
DFXPWriter,
|
|
@@ -108,6 +110,30 @@ class TestWebVTTtoWebVTT(WebVTTTestingMixIn):
|
|
|
108
110
|
assert re.match(expected_timestamp_line_pattern, results[5])
|
|
109
111
|
|
|
110
112
|
|
|
113
|
+
class TestWebVTTEmptyCueConversion:
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
"writer", [WebVTTWriter, SRTWriter, DFXPWriter, SAMIWriter]
|
|
116
|
+
)
|
|
117
|
+
@pytest.mark.parametrize("ignore_timing_errors", [True, False])
|
|
118
|
+
@pytest.mark.parametrize(
|
|
119
|
+
"fixture_name, leaked_text",
|
|
120
|
+
[
|
|
121
|
+
("sample_webvtt_empty_cue_then_id", "cue-2"),
|
|
122
|
+
("sample_webvtt_empty_cue_then_note", "hello"),
|
|
123
|
+
("sample_webvtt_empty_cue_multiple_blanks", "cue-2"),
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
def test_no_leak_after_empty_cue(
|
|
127
|
+
self, request, fixture_name, leaked_text, ignore_timing_errors, writer
|
|
128
|
+
):
|
|
129
|
+
caption_set = WebVTTReader(ignore_timing_errors=ignore_timing_errors).read(
|
|
130
|
+
request.getfixturevalue(fixture_name)
|
|
131
|
+
)
|
|
132
|
+
results = writer().write(caption_set)
|
|
133
|
+
|
|
134
|
+
assert leaked_text not in results
|
|
135
|
+
|
|
136
|
+
|
|
111
137
|
# # TODO: Write a test that includes a WebVTT file with style tags
|
|
112
138
|
# # That will fail because the styles used in the cues are not tracked.
|
|
113
139
|
|
|
@@ -1383,3 +1409,31 @@ class TestMultipleLayoutsPerCaption:
|
|
|
1383
1409
|
assert len(cues) == 2
|
|
1384
1410
|
assert cues[0][1] == ["plain"]
|
|
1385
1411
|
assert cues[1][1] == ["styled<i>"]
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
class TestWebVTTNegativeTimeShiftConversion:
|
|
1415
|
+
@pytest.mark.parametrize("shift", [-1000, -1500, -2000])
|
|
1416
|
+
@pytest.mark.parametrize(
|
|
1417
|
+
"writer", [WebVTTWriter, SRTWriter, DFXPWriter, SAMIWriter]
|
|
1418
|
+
)
|
|
1419
|
+
def test_no_wrapped_timestamp_written(
|
|
1420
|
+
self, sample_webvtt_negative_shift, writer, shift
|
|
1421
|
+
):
|
|
1422
|
+
caption_set = WebVTTReader(time_shift_milliseconds=shift).read(
|
|
1423
|
+
sample_webvtt_negative_shift
|
|
1424
|
+
)
|
|
1425
|
+
output = writer().write(caption_set)
|
|
1426
|
+
|
|
1427
|
+
assert "23:59:" not in output
|
|
1428
|
+
assert 'start="-' not in output
|
|
1429
|
+
|
|
1430
|
+
@pytest.mark.parametrize("shift", [-1000, -1500, -2000])
|
|
1431
|
+
def test_webvtt_output_end_after_start(self, sample_webvtt_negative_shift, shift):
|
|
1432
|
+
caption_set = WebVTTReader(time_shift_milliseconds=shift).read(
|
|
1433
|
+
sample_webvtt_negative_shift
|
|
1434
|
+
)
|
|
1435
|
+
output = WebVTTWriter().write(caption_set)
|
|
1436
|
+
timings = re.findall(r"(\S+) --> (\S+)", output)
|
|
1437
|
+
|
|
1438
|
+
assert timings
|
|
1439
|
+
assert all(start < end for start, end in timings)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|