Files
skill-materialsub/skills/materialsub/tests/test_subtitle_pipeline.py
T
hugo e27cc22cfb Initial MaterialSub release
Derived from pengchujin/jzsub at 222a90265d2a8797ca258eb1a980cee0863a8311; preserve the upstream MIT license and attribution.
2026-08-25 13:07:03 +08:00

682 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "subtitle_pipeline.py"
SPEC = importlib.util.spec_from_file_location("subtitle_pipeline", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
pipeline = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(pipeline)
def srt(cues: list[tuple[str, str, str]], newline: str = "\n") -> bytes:
blocks = []
for index, (start, end, text) in enumerate(cues, start=1):
blocks.append(f"{index}{newline}{start} --> {end}{newline}{text}")
return (newline + newline).join(blocks).encode("utf-8") + newline.encode("ascii")
class SubtitlePipelineTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
def tearDown(self) -> None:
self.temporary.cleanup()
def prepare_fixture(
self,
raw: bytes,
*,
segment_mode: str = "preserve",
source_language: str = "en",
video_size: tuple[int, int] | None = None,
) -> tuple[Path, dict]:
source = self.root / "downloaded.srt"
source.write_bytes(raw)
manifest_path = pipeline.prepare(
source, self.root / "work", source_language, segment_mode, video_size
)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
return manifest_path, manifest
def write_translations(
self,
manifest: dict,
*,
records: list[dict] | None = None,
filename: str = "translations.json",
) -> Path:
directory = self.root / "translations"
directory.mkdir(exist_ok=True)
if records is None:
records = [
{
"id": segment["id"],
"translation": f"中文 {index}",
}
for index, segment in enumerate(manifest["segments"], start=1)
]
(directory / filename).write_text(
json.dumps({"translations": records}, ensure_ascii=False),
encoding="utf-8",
)
return directory
def test_compact_batches_omit_model_visible_hashes(self) -> None:
manifest_path, manifest = self.prepare_fixture(
srt([("00:00:00,000", "00:00:01,000", "Hello world")])
)
self.assertEqual(manifest["translation_contract_version"], 4)
batch = json.loads(
Path(manifest["translation_batches"][0]["path"]).read_text(encoding="utf-8")
)
self.assertEqual(set(batch["items"][0]), {"id", "source"})
self.assertNotIn("source_sha256", json.dumps(batch))
self.assertEqual(batch["output_fields"], ["id", "translation"])
translations_dir = self.write_translations(manifest)
pipeline.render(manifest_path, translations_dir, self.root / "output")
def test_short_subtitles_fit_one_translation_batch(self) -> None:
cues = [
(f"00:00:{index:02d},000", f"00:00:{index:02d},900", f"Line {index}")
for index in range(25)
]
manifest_path, manifest = self.prepare_fixture(srt(cues))
first = pipeline.next_translation_batch(manifest_path)
self.assertFalse(first["done"])
self.assertEqual(first["remaining"], 1)
self.assertEqual(len(first["batch"]["items"]), 25)
self.assertEqual(first["batch"]["context"], {"before": [], "after": []})
self.assertNotIn("segments", first)
self.assertNotIn("cues", first)
self.assertNotIn("source_sha256", json.dumps(first))
output = Path(first["output_path"])
output.write_text(
json.dumps(
{"translations": [
{"id": item["id"], "translation": "中文"}
for item in first["batch"]["items"]
]},
ensure_ascii=False,
),
encoding="utf-8",
)
complete = pipeline.next_translation_batch(manifest_path)
self.assertTrue(complete["done"])
self.assertEqual(complete["remaining"], 0)
def test_long_subtitles_split_into_bounded_context_linked_batches(self) -> None:
total = pipeline.TRANSLATION_BATCH_SIZE * 2 + 5
cues = [
(
f"{index // 3600:02d}:{index // 60 % 60:02d}:{index % 60:02d},000",
f"{index // 3600:02d}:{index // 60 % 60:02d}:{index % 60:02d},900",
f"Line {index}",
)
for index in range(total)
]
manifest_path, manifest = self.prepare_fixture(srt(cues))
batches = manifest["translation_batches"]
self.assertEqual(len(batches), 3)
segment_ids = [segment["id"] for segment in manifest["segments"]]
batched = [batch_id for batch in batches for batch_id in batch["segment_ids"]]
self.assertEqual(batched, segment_ids)
second = json.loads(Path(batches[1]["path"]).read_text(encoding="utf-8"))
size = pipeline.TRANSLATION_BATCH_SIZE
context_span = pipeline.TRANSLATION_CONTEXT_SEGMENTS
self.assertEqual(len(second["items"]), size)
self.assertEqual(
[item["id"] for item in second["context"]["before"]],
segment_ids[size - context_span : size],
)
self.assertEqual(
[item["id"] for item in second["context"]["after"]],
segment_ids[2 * size : 2 * size + context_span],
)
remaining = len(batches)
while True:
pending = pipeline.next_translation_batch(manifest_path)
if pending["done"]:
break
self.assertEqual(pending["remaining"], remaining)
Path(pending["output_path"]).write_text(
json.dumps(
{"translations": [
{"id": item["id"], "translation": "中文"}
for item in pending["batch"]["items"]
]},
ensure_ascii=False,
),
encoding="utf-8",
)
remaining -= 1
self.assertEqual(remaining, 0)
pipeline.load_translations(manifest, self.root / "work" / "translation-output")
def test_ass_stacks_source_above_chinese_at_the_bottom(self) -> None:
manifest_path, manifest = self.prepare_fixture(
srt([
("00:00:00,000", "00:00:02,000", "Short"),
(
"00:00:02,100",
"00:00:05,000",
"A much longer source caption that wraps onto another display line while preserving its exact text",
),
])
)
translations_dir = self.write_translations(
manifest,
records=[
{"id": manifest["segments"][0]["id"], "translation": "短句"},
{"id": manifest["segments"][1]["id"], "translation": "这是一条会换行的较长中文字幕 用来验证位置固定"},
],
)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
rendered = output_dir.joinpath("bilingual.ass").read_text(encoding="utf-8")
text_events = [line for line in rendered.splitlines() if ",Bilingual," in line]
box_events = [line for line in rendered.splitlines() if ",BilingualBox," in line]
self.assertEqual(len(text_events), 2)
self.assertEqual(len(box_events), 2)
# One bottom-anchored stack: source at fs42 above Chinese at fs46.
for line in text_events + box_events:
self.assertIn(r"{\an2\pos(960,1030)\fs42}", line)
for line in text_events:
self.assertIn(r"\N{\fs46\1c&H00FFFF&}", line)
for line in box_events:
self.assertIn(r"\N{\fs46}", line)
self.assertNotIn(r"\1c", line)
def test_portrait_video_gets_matching_playres_and_narrower_wrapping(self) -> None:
manifest_path, manifest = self.prepare_fixture(
srt([
(
"00:00:00,000",
"00:00:03,000",
"A long landscape-width caption that must wrap much earlier on a portrait video",
)
]),
video_size=(1080, 1920),
)
self.assertEqual(manifest["video_size"], {"width": 1080, "height": 1920})
translations_dir = self.write_translations(
manifest,
records=[
{
"id": manifest["segments"][0]["id"],
"translation": "竖屏视频中的中文字幕必须按较窄的宽度换行",
}
],
)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
rendered = output_dir.joinpath("bilingual.ass").read_text(encoding="utf-8")
layout = pipeline._ass_layout(manifest)
self.assertEqual(layout["play_res_x"], round(1080 * 1080 / 1920))
self.assertEqual(layout["source_font_size"], 36)
self.assertEqual(layout["target_font_size"], 40)
self.assertEqual(layout["bottom_margin"], 120)
self.assertEqual(layout["position_y"], 960)
self.assertLess(layout["target_columns"], pipeline.TARGET_WRAP_COLUMNS)
self.assertLess(layout["source_columns"], pipeline.SOURCE_WRAP_COLUMNS)
self.assertIn(f"PlayResX: {layout['play_res_x']}", rendered)
self.assertIn("PlayResY: 1080", rendered)
self.assertIn(r"{\an2\pos(304,960)\fs36}", rendered)
self.assertIn(r"\N{\fs40\1c&H00FFFF&}", rendered)
chinese_srt = output_dir.joinpath("zh-CN.srt").read_text(encoding="utf-8")
chinese_lines = [line for line in chinese_srt.splitlines()[2:] if line]
self.assertGreater(len(chinese_lines), 1)
def clear_translations(self) -> None:
directory = self.root / "translations"
if directory.exists():
for path in directory.glob("*.json"):
path.unlink()
def test_raw_archive_and_source_text_are_exactly_preserved(self) -> None:
raw = b"\xef\xbb\xbf" + srt(
[
("00:00:00,100", "00:00:01,500", " Café & co. "),
("00:00:01,700", "00:00:03,000", "Line one\r\nLine two"),
],
newline="\r\n",
)
manifest_path, manifest = self.prepare_fixture(raw)
archive = Path(manifest["source"]["archive_path"])
self.assertEqual(archive.read_bytes(), raw)
self.assertTrue((self.root / "work" / "translation-output").is_dir())
self.assertEqual(manifest["cues"][0]["text"], " Café & co. ")
self.assertEqual(manifest["cues"][1]["text"], "Line one\nLine two")
original_path = Path(manifest["source"]["original_path"])
original_path.write_bytes(
srt([("00:00:00,000", "00:00:01,000", "Different source")])
)
with self.assertRaisesRegex(pipeline.PipelineError, "write-once source archive"):
pipeline.prepare(original_path, self.root / "work", "en")
self.assertEqual(archive.read_bytes(), raw)
batch_path = Path(manifest["translation_batches"][0]["path"])
batch = json.loads(batch_path.read_text(encoding="utf-8"))
self.assertEqual(batch["items"][0]["source"], " Café & co. ")
self.assertEqual(batch["output_fields"], ["id", "translation"])
self.assertNotIn("source", batch["output_fields"])
translations_dir = self.write_translations(manifest)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
pipeline.validate(manifest_path, translations_dir, output_dir)
source_output = output_dir.joinpath("source.srt").read_text(encoding="utf-8")
self.assertIn(" Café & co. ", source_output)
self.assertIn("Line one\nLine two", source_output)
report = json.loads(output_dir.joinpath("validation.json").read_text())
self.assertTrue(report["structurally_valid"])
self.assertEqual(report["validation_scope"], "structural_source_integrity")
self.assertEqual(report["font"], "MiSans")
self.assertEqual(report["font_weight"], 700)
self.assertFalse(report["translation_quality_reviewed"])
self.assertTrue(report["invariants"]["raw_source_sha256_locked"])
def test_ass_malicious_text_is_losslessly_escaped(self) -> None:
malicious = r"Literal {\pos(10,20)} \N \n \h } { 中文"
raw = srt([("00:00:00,000", "00:00:02,000", malicious)])
manifest_path, manifest = self.prepare_fixture(raw)
segment = manifest["segments"][0]
translation = r"中文 {\move(0,0,9,9)} \N"
translations_dir = self.write_translations(
manifest,
records=[
{
"id": segment["id"],
"source_sha256": segment["source_sha256"],
"translation": translation,
}
],
)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
self.assertEqual(
pipeline.ass_unescape_for_validation(pipeline.ass_escape(malicious)), malicious
)
self.assertEqual(
pipeline.ass_unescape_for_validation(pipeline.ass_escape(translation)), translation
)
rendered = output_dir.joinpath("bilingual.ass").read_text(encoding="utf-8")
self.assertNotIn(r"{\pos(10,20)}", rendered)
self.assertNotIn(r"{\move(0,0,9,9)}", rendered)
pipeline.validate(manifest_path, translations_dir, output_dir)
def test_ass_ffmpeg_guards_round_trip_all_reserved_sequences(self) -> None:
word_joiner = pipeline.ASS_WORD_JOINER
cases = [
r"{\pos(1,2)}\N",
r"literal \N, \n, and \h",
"opening { and closing } braces",
f"existing{word_joiner}word-joiner",
"\\" + word_joiner + "N",
"line one\nline two",
"Unicode 中文 👩\u200d🚀 e\u0301",
]
for source in cases:
with self.subTest(source=source):
encoded = pipeline.ass_escape(source)
self.assertEqual(pipeline.ass_unescape_for_validation(encoded), source)
self.assertEqual(pipeline.ass_escape("\\N"), "\\" + word_joiner + "N")
self.assertEqual(pipeline.ass_escape("\\n"), "\\" + word_joiner + "n")
self.assertEqual(pipeline.ass_escape("\\h"), "\\" + word_joiner + "h")
self.assertEqual(pipeline.ass_escape("{"), r"\{{}")
self.assertEqual(pipeline.ass_escape("\n"), r"\N")
with self.assertRaisesRegex(pipeline.PipelineError, "unguarded backslash"):
pipeline.ass_unescape_for_validation(r"\h")
def test_translation_contract_rejects_missing_duplicate_and_hash_mismatch(self) -> None:
raw = srt(
[
("00:00:00,000", "00:00:01,000", "One"),
("00:00:01,100", "00:00:02,000", "Two"),
]
)
_, manifest = self.prepare_fixture(raw)
first, second = manifest["segments"]
translations_dir = self.write_translations(
manifest,
records=[
{
"id": first["id"],
"source_sha256": first["source_sha256"],
"translation": "",
}
],
)
with self.assertRaisesRegex(pipeline.PipelineError, "missing translations"):
pipeline.load_translations(manifest, translations_dir)
self.clear_translations()
valid_first = {
"id": first["id"],
"source_sha256": first["source_sha256"],
"translation": "",
}
self.write_translations(manifest, records=[valid_first], filename="a.json")
self.write_translations(manifest, records=[valid_first], filename="b.json")
with self.assertRaisesRegex(pipeline.PipelineError, "duplicate translation ID"):
pipeline.load_translations(manifest, translations_dir)
self.clear_translations()
records = [
{**valid_first, "source_sha256": "0" * 64},
{
"id": second["id"],
"source_sha256": second["source_sha256"],
"translation": "",
},
]
self.write_translations(manifest, records=records)
with self.assertRaisesRegex(pipeline.PipelineError, "source SHA-256 mismatch"):
pipeline.load_translations(manifest, translations_dir)
def test_translation_contract_rejects_editable_source_and_controls(self) -> None:
manifest_path, manifest = self.prepare_fixture(
srt([("00:00:00,000", "00:00:01,000", "Source")])
)
del manifest_path
segment = manifest["segments"][0]
forbidden = {
"id": segment["id"],
"source_sha256": segment["source_sha256"],
"source": "rewritten",
"translation": "中文",
}
translations_dir = self.write_translations(manifest, records=[forbidden])
with self.assertRaisesRegex(pipeline.PipelineError, "forbidden/missing fields"):
pipeline.load_translations(manifest, translations_dir)
self.clear_translations()
controlled = {
"id": segment["id"],
"source_sha256": segment["source_sha256"],
"translation": "\n",
}
self.write_translations(manifest, records=[controlled])
with self.assertRaisesRegex(pipeline.PipelineError, "control character"):
pipeline.load_translations(manifest, translations_dir)
def test_unicode_layout_inserts_breaks_without_changing_source(self) -> None:
text = "👩\u200d🚀e\u0301 العربية 中文🙂 and-more-text"
chunks = pipeline.wrap_layout_chunks(text, 6)
self.assertEqual("".join(chunks), text)
self.assertTrue(any("👩\u200d🚀" in chunk for chunk in chunks))
manifest_path, manifest = self.prepare_fixture(
srt([("00:00:00,000", "00:00:03,000", text)]),
source_language="ar",
)
translations_dir = self.write_translations(manifest)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir, "Noto Sans")
pipeline.validate(manifest_path, translations_dir, output_dir, "Noto Sans")
def test_chinese_house_style_and_measured_background(self) -> None:
source = "This English subtitle is intentionally longer than forty-two columns but should remain on one display line"
manifest_path, manifest = self.prepare_fixture(
srt([("00:00:00,000", "00:00:03,000", source)])
)
segment = manifest["segments"][0]
translations_dir = self.write_translations(
manifest,
records=[
{
"id": segment["id"],
"source_sha256": segment["source_sha256"],
"translation": "你好,世界。",
}
],
)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
self.assertEqual(pipeline.normalize_target_caption("你好,世界。", "zh-CN"), "你好 世界")
self.assertEqual(pipeline.normalize_target_caption("版本 5.6,发布。", "zh-CN"), "版本 5.6 发布")
chinese_srt = output_dir.joinpath("zh-CN.srt").read_text(encoding="utf-8")
self.assertIn("你好 世界", chinese_srt)
self.assertNotIn("", chinese_srt)
self.assertNotIn("", chinese_srt)
source_srt = output_dir.joinpath("source.srt").read_text(encoding="utf-8")
source_lines = source_srt.splitlines()[2:]
self.assertGreater(max(map(len, source_lines)), 42)
self.assertLessEqual(max(map(len, source_lines)), pipeline.SOURCE_WRAP_COLUMNS)
rendered = output_dir.joinpath("bilingual.ass").read_text(encoding="utf-8")
self.assertIn("Style: Bilingual,MiSans,46", rendered)
self.assertIn("Style: BilingualBox,MiSans,46", rendered)
self.assertIn(",4,8,0,2,80,80,50,1", rendered)
self.assertIn("Dialogue: 0,", rendered)
self.assertNotIn(r"{\an7\p1}", rendered)
self.assertIn("Dialogue: 1,", rendered)
pipeline.validate(manifest_path, translations_dir, output_dir)
def test_background_uses_identical_text_layout_for_libass_measurement(self) -> None:
source = "日本語の字幅は Latin text と同じではありません"
manifest_path, manifest = self.prepare_fixture(
srt([("00:00:00,000", "00:00:03,000", source)]),
source_language="ja",
)
segment = manifest["segments"][0]
translations_dir = self.write_translations(
manifest,
records=[
{
"id": segment["id"],
"source_sha256": segment["source_sha256"],
"translation": "日文字形宽度与拉丁文字不同",
}
],
)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
rendered = output_dir.joinpath("bilingual.ass").read_text(encoding="utf-8")
self.assertIn("Style: BilingualBox,MiSans,46", rendered)
self.assertIn(",4,8,0,2,80,80,50,1", rendered)
self.assertNotIn(r"{\an7\p1}", rendered)
self.assertIn(
r"BilingualBox,,0,0,0,,{\an2\pos(960,1030)\fs42}"
+ source
+ r"\N{\fs46}日文字形宽度与拉丁文字不同",
rendered,
)
self.assertIn(
r"Bilingual,,0,0,0,,{\an2\pos(960,1030)\fs42}"
+ source
+ r"\N{\fs46\1c&H00FFFF&}日文字形宽度与拉丁文字不同",
rendered,
)
def test_smart_mode_groups_only_whole_adjacent_cues(self) -> None:
raw = srt(
[
("00:00:00,000", "00:00:00,900", "Hello"),
("00:00:01,000", "00:00:02,000", "world."),
("00:00:04,000", "00:00:05,000", "Separate."),
]
)
manifest_path, manifest = self.prepare_fixture(raw, segment_mode="smart")
self.assertEqual(len(manifest["segments"]), 3)
self.assertEqual(len(manifest["render_segments"]), 2)
self.assertEqual(
manifest["render_segments"][0]["cue_ids"],
[manifest["cues"][0]["id"], manifest["cues"][1]["id"]],
)
self.assertEqual(manifest["cues"][0]["text"], "Hello")
self.assertEqual(manifest["cues"][1]["text"], "world.")
covered = [cue_id for segment in manifest["render_segments"] for cue_id in segment["cue_ids"]]
self.assertEqual(covered, [cue["id"] for cue in manifest["cues"]])
translations_dir = self.write_translations(manifest)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
rendered = output_dir.joinpath("source.srt").read_text(encoding="utf-8")
self.assertIn("Hello\nworld.", rendered)
chinese = output_dir.joinpath("zh-CN.srt").read_text(encoding="utf-8")
self.assertIn("中文 1 中文 2", chinese)
pipeline.validate(manifest_path, translations_dir, output_dir)
def test_chinese_lines_use_the_full_shared_width_budget(self) -> None:
manifest_path, manifest = self.prepare_fixture(
srt([("00:00:00,000", "00:00:03,000", "A test line.")])
)
one_line = "这条二十五个汉字长度的中文字幕不应被折成两行显示"
self.assertEqual(len(one_line), 24)
translations_dir = self.write_translations(
manifest,
records=[{"id": manifest["segments"][0]["id"], "translation": one_line}],
)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
chinese_srt = output_dir.joinpath("zh-CN.srt").read_text(encoding="utf-8")
chinese_lines = [line for line in chinese_srt.splitlines()[2:] if line]
self.assertEqual(chinese_lines, [one_line])
def test_japanese_target_keeps_punctuation_and_names_outputs(self) -> None:
self.assertEqual(
pipeline.normalize_target_caption("こんにちは、世界。", "ja"),
"こんにちは、世界。",
)
source = self.root / "downloaded.srt"
source.write_bytes(srt([("00:00:00,000", "00:00:02,000", "Hello world.")]))
manifest_path = pipeline.prepare(
source, self.root / "work", "en", "preserve", None, "ja"
)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["target_language"], "ja")
batch = json.loads(
Path(manifest["translation_batches"][0]["path"]).read_text(encoding="utf-8")
)
self.assertEqual(batch["target_language"], "ja")
translations_dir = self.write_translations(
manifest,
records=[
{"id": manifest["segments"][0]["id"], "translation": "こんにちは、世界。"}
],
)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
ja_srt = output_dir.joinpath("ja.srt").read_text(encoding="utf-8")
self.assertIn("こんにちは、世界。", ja_srt)
self.assertFalse(output_dir.joinpath("zh-CN.srt").exists())
report = json.loads(output_dir.joinpath("validation.json").read_text())
self.assertEqual(report["target_language"], "ja")
pipeline.validate(manifest_path, translations_dir, output_dir)
def test_rejects_invalid_target_language_tag(self) -> None:
source = self.root / "downloaded.srt"
source.write_bytes(srt([("00:00:00,000", "00:00:01,000", "Hi")]))
with self.assertRaisesRegex(pipeline.PipelineError, "--target-language"):
pipeline.prepare(source, self.root / "work", "en", "preserve", None, "bad lang!!")
def test_sound_annotation_cues_are_excluded_from_translation(self) -> None:
self.assertTrue(pipeline.is_non_dialogue_annotation("[Music]"))
self.assertTrue(pipeline.is_non_dialogue_annotation("[Applause] [Laughter]"))
self.assertTrue(pipeline.is_non_dialogue_annotation("【音乐】"))
self.assertTrue(pipeline.is_non_dialogue_annotation("(拍手)"))
self.assertTrue(pipeline.is_non_dialogue_annotation("♪♪"))
self.assertTrue(pipeline.is_non_dialogue_annotation("♪ [upbeat music] ♪"))
self.assertFalse(pipeline.is_non_dialogue_annotation("[Applause] Thank you"))
self.assertFalse(pipeline.is_non_dialogue_annotation("Hello (world)"))
self.assertFalse(pipeline.is_non_dialogue_annotation("「こんにちは」"))
self.assertFalse(pipeline.is_non_dialogue_annotation("Plain dialogue."))
raw = srt(
[
("00:00:00,000", "00:00:01,000", "[Music]"),
("00:00:01,100", "00:00:02,000", "Real dialogue starts."),
("00:00:02,100", "00:00:03,000", ""),
("00:00:03,100", "00:00:04,000", "[Applause] Thanks everyone."),
]
)
manifest_path, manifest = self.prepare_fixture(raw, segment_mode="smart")
texts = [cue["text"] for cue in manifest["cues"]]
self.assertEqual(texts, ["Real dialogue starts.", "[Applause] Thanks everyone."])
self.assertEqual(len(manifest["segments"]), 2)
translations_dir = self.write_translations(manifest)
output_dir = self.root / "output"
pipeline.render(manifest_path, translations_dir, output_dir)
rendered = output_dir.joinpath("source.srt").read_text(encoding="utf-8")
self.assertNotIn("[Music]", rendered)
self.assertNotIn("", rendered)
pipeline.validate(manifest_path, translations_dir, output_dir)
def test_annotation_only_subtitles_raise_no_dialogue(self) -> None:
raw = srt(
[
("00:00:00,000", "00:00:01,000", "[Music]"),
("00:00:01,100", "00:00:02,000", "【背景音乐】"),
]
)
source = self.root / "downloaded.srt"
source.write_bytes(raw)
with self.assertRaises(pipeline.NoDialogueError):
pipeline.prepare(source, self.root / "work", "en")
def test_smart_mode_closes_groups_exactly_at_sentence_boundaries(self) -> None:
raw = srt(
[
("00:00:00,000", "00:00:01,000", "This is the first"),
("00:00:01,100", "00:00:02,000", "half of a sentence."),
("00:00:02,100", "00:00:03,000", "Next sentence starts"),
("00:00:03,100", "00:00:04,000", "and keeps going"),
]
)
_, manifest = self.prepare_fixture(raw, segment_mode="smart")
cues = manifest["cues"]
groups = [segment["cue_ids"] for segment in manifest["render_segments"]]
self.assertEqual(
groups,
[
[cues[0]["id"], cues[1]["id"]],
[cues[2]["id"], cues[3]["id"]],
],
)
def test_smart_mode_clamps_rolling_caption_overlap(self) -> None:
raw = srt(
[
("00:00:00,000", "00:00:04,000", "First sentence."),
("00:00:02,000", "00:00:06,000", "Second sentence."),
("00:00:05,000", "00:00:07,000", "Third sentence."),
]
)
_, manifest = self.prepare_fixture(raw, segment_mode="smart")
self.assertEqual(len(manifest["segments"]), 3)
self.assertEqual(manifest["cues"][0]["end_ms"], 4000)
self.assertEqual(manifest["render_segments"][0]["end_ms"], 2000)
self.assertEqual(manifest["render_segments"][1]["end_ms"], 5000)
for current, following in zip(manifest["render_segments"], manifest["render_segments"][1:]):
self.assertLessEqual(current["end_ms"], following["start_ms"])
if __name__ == "__main__":
unittest.main()