Initial MaterialSub release
Derived from pengchujin/jzsub at 222a90265d2a8797ca258eb1a980cee0863a8311; preserve the upstream MIT license and attribution.
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "burn_subtitles.py"
|
||||
SPEC = importlib.util.spec_from_file_location("burn_subtitles", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
burn = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(burn)
|
||||
|
||||
|
||||
class BurnSubtitleValidationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
self.subtitle = self.root / "bilingual.ass"
|
||||
self.subtitle.write_bytes(b"[Script Info]\nTitle: test\n")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def report(self, **overrides: object) -> dict[str, object]:
|
||||
value: dict[str, object] = {
|
||||
"structurally_valid": True,
|
||||
"validation_scope": "structural_source_integrity",
|
||||
"segment_count": 2,
|
||||
"translation_count": 2,
|
||||
"outputs": {
|
||||
"bilingual.ass": hashlib.sha256(self.subtitle.read_bytes()).hexdigest()
|
||||
},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
def write_report(self, value: dict[str, object] | None = None) -> Path:
|
||||
path = self.root / "validation.json"
|
||||
path.write_text(
|
||||
json.dumps(value if value is not None else self.report()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
def test_accepts_matching_structural_validation_report(self) -> None:
|
||||
self.write_report()
|
||||
|
||||
validated = burn._validate_validation_report(
|
||||
self.subtitle, self.root / "validation.json"
|
||||
)
|
||||
|
||||
self.assertEqual(validated["segment_count"], 2)
|
||||
self.assertTrue(validated["structurally_valid"])
|
||||
|
||||
def test_missing_validated_font_fails_closed_with_override(self) -> None:
|
||||
report = self.report(font="MiSans")
|
||||
|
||||
with mock.patch.object(burn, "_font_installed", return_value=False):
|
||||
with self.assertRaisesRegex(burn.BurnError, "MiSans.*not found"):
|
||||
burn._require_subtitle_font(report, allow_missing_font=False)
|
||||
burn._require_subtitle_font(report, allow_missing_font=True)
|
||||
|
||||
with mock.patch.object(burn, "_font_installed", return_value=True):
|
||||
burn._require_subtitle_font(report, allow_missing_font=False)
|
||||
|
||||
with mock.patch.object(burn, "_font_installed", return_value=None):
|
||||
burn._require_subtitle_font(report, allow_missing_font=False)
|
||||
|
||||
def test_reports_without_a_font_skip_the_font_gate(self) -> None:
|
||||
with mock.patch.object(burn, "_font_installed") as detect:
|
||||
burn._require_subtitle_font(self.report(), allow_missing_font=False)
|
||||
detect.assert_not_called()
|
||||
|
||||
def test_rejects_stale_ass_checksum(self) -> None:
|
||||
report = self.write_report()
|
||||
self.subtitle.write_bytes(self.subtitle.read_bytes() + b"stale")
|
||||
|
||||
with self.assertRaisesRegex(burn.BurnError, "bilingual.ass.*SHA-256"):
|
||||
burn._validate_validation_report(self.subtitle, report)
|
||||
|
||||
def test_rejects_missing_structural_report(self) -> None:
|
||||
missing = self.root / "missing-validation.json"
|
||||
|
||||
with self.assertRaisesRegex(burn.BurnError, "validation report.*does not exist"):
|
||||
burn._validate_validation_report(self.subtitle, missing)
|
||||
|
||||
def test_rejects_report_without_structural_approval(self) -> None:
|
||||
report = self.write_report(self.report(structurally_valid=False))
|
||||
|
||||
with self.assertRaisesRegex(burn.BurnError, "structurally_valid=true"):
|
||||
burn._validate_validation_report(self.subtitle, report)
|
||||
|
||||
def test_rejects_wrong_validation_scope(self) -> None:
|
||||
report = self.write_report(self.report(validation_scope="translation_review"))
|
||||
|
||||
with self.assertRaisesRegex(burn.BurnError, "structural_source_integrity"):
|
||||
burn._validate_validation_report(self.subtitle, report)
|
||||
|
||||
def test_cli_accepts_validation_report_override(self) -> None:
|
||||
args = burn._parser().parse_args(
|
||||
[
|
||||
"input.mp4",
|
||||
"bilingual.ass",
|
||||
"output.mp4",
|
||||
"--validation-report",
|
||||
"reviewed.json",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(args.validation_report, Path("reviewed.json"))
|
||||
|
||||
def test_cli_accepts_citation_file(self) -> None:
|
||||
args = burn._parser().parse_args(
|
||||
["input.mp4", "bilingual.ass", "output.mp4", "--citation-file", "citation.txt"]
|
||||
)
|
||||
|
||||
self.assertEqual(args.citation_file, Path("citation.txt"))
|
||||
|
||||
def test_citation_ass_is_top_left_and_spans_video(self) -> None:
|
||||
rendered = burn._render_citation_ass(
|
||||
"内容引自网络,仅供内部交流\n\n"
|
||||
"Huang, C., Jiang, N., Zheng, X., Gu, H., Zhang, L., Ou, S.\n"
|
||||
"A Protocol for Harvesting Single-cell Suspension from Mouse Corneas.\n"
|
||||
"J. Vis. Exp. (230), e69844, doi:10.3791/69844 (2026).",
|
||||
1920,
|
||||
1080,
|
||||
61.23,
|
||||
)
|
||||
|
||||
self.assertIn("Style: Citation,Arial,24", rendered)
|
||||
self.assertIn(r"\pos(40,30)\p1", rendered)
|
||||
self.assertIn("m 0 0 l 720 0 720 132 0 132", rendered)
|
||||
self.assertIn(r"\fs20\1c&HCCCCCC&", rendered)
|
||||
self.assertIn("内容引自网络,仅供内部交流", rendered)
|
||||
self.assertIn(r"\fs24\1c&HFFFFFF&", rendered)
|
||||
self.assertEqual(rendered.count("Dialogue:"), 2)
|
||||
self.assertIn("Dialogue: 10,0:00:00.00,0:01:01.23", rendered)
|
||||
self.assertIn("doi:10.3791/69844", rendered)
|
||||
|
||||
def test_citation_ass_guards_untrusted_override_sequences(self) -> None:
|
||||
rendered = burn._render_citation_ass(
|
||||
r"Title {\pos(1,1)} and literal \N text",
|
||||
1920,
|
||||
1080,
|
||||
5.0,
|
||||
)
|
||||
dialogue = rendered.rsplit("Dialogue: ", 1)[1]
|
||||
|
||||
self.assertNotIn(r"{\pos(1,1)}", dialogue)
|
||||
self.assertIn("\\" + burn.ASS_WORD_JOINER + "pos", dialogue)
|
||||
self.assertIn("\\" + burn.ASS_WORD_JOINER + "N", dialogue)
|
||||
|
||||
def test_encode_command_layers_citation_in_same_video_filter(self) -> None:
|
||||
citation_ass = self.root / "citation.ass"
|
||||
command, _ = burn._encode_command(
|
||||
"ffmpeg",
|
||||
self.root / "input.mkv",
|
||||
self.subtitle,
|
||||
self.root / "output.mp4",
|
||||
{"index": 0},
|
||||
[],
|
||||
force=False,
|
||||
crf=18,
|
||||
preset="slow",
|
||||
encoder="libx264",
|
||||
citation_ass=citation_ass,
|
||||
)
|
||||
|
||||
video_filter = command[command.index("-vf") + 1]
|
||||
self.assertEqual(video_filter.count("subtitles=filename="), 2)
|
||||
self.assertIn(str(citation_ass), video_filter)
|
||||
|
||||
def test_citation_receipt_binds_output_and_text_hashes(self) -> None:
|
||||
output = self.root / "output.mp4"
|
||||
output.write_bytes(b"video")
|
||||
citation_file = self.root / "citation.txt"
|
||||
citation_file.write_text("Formal citation.\n", encoding="utf-8")
|
||||
|
||||
receipt = burn._write_citation_receipt(output, citation_file)
|
||||
value = json.loads(receipt.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(value["output_file"], output.name)
|
||||
self.assertEqual(value["output_sha256"], hashlib.sha256(b"video").hexdigest())
|
||||
self.assertEqual(value["citation_sha256"], hashlib.sha256(citation_file.read_bytes()).hexdigest())
|
||||
self.assertEqual(value["position"], "top-left")
|
||||
|
||||
def test_selects_libass_capable_ffmpeg_full_when_path_build_lacks_it(self) -> None:
|
||||
default = self.root / "bin" / "ffmpeg"
|
||||
full = self.root / "opt" / "ffmpeg-full" / "bin" / "ffmpeg"
|
||||
default.parent.mkdir(parents=True)
|
||||
full.parent.mkdir(parents=True)
|
||||
default.write_text("", encoding="utf-8")
|
||||
full.write_text("", encoding="utf-8")
|
||||
|
||||
with mock.patch.object(
|
||||
burn,
|
||||
"_ffmpeg_has_subtitles_filter",
|
||||
side_effect=lambda path: Path(path) == full,
|
||||
):
|
||||
selected = burn._select_libass_ffmpeg(str(default), candidates=[full])
|
||||
|
||||
self.assertEqual(selected, str(full))
|
||||
|
||||
def test_progress_bar_is_compact_and_human_readable(self) -> None:
|
||||
line = burn._format_progress(50, 71.5, 143.0, "0.68x")
|
||||
|
||||
self.assertEqual(
|
||||
line,
|
||||
"烧录 [██████████░░░░░░░░░░] 50% 01:11 / 02:23 0.68x",
|
||||
)
|
||||
self.assertLess(len(line), 80)
|
||||
|
||||
def test_encode_command_uses_machine_readable_quiet_progress(self) -> None:
|
||||
command, _ = burn._encode_command(
|
||||
"ffmpeg",
|
||||
self.root / "input.mkv",
|
||||
self.subtitle,
|
||||
self.root / "output.mp4",
|
||||
{"index": 0},
|
||||
[],
|
||||
force=False,
|
||||
crf=18,
|
||||
preset="slow",
|
||||
encoder="libx264",
|
||||
)
|
||||
|
||||
self.assertIn("-nostats", command)
|
||||
self.assertEqual(command[command.index("-loglevel") + 1], "error")
|
||||
self.assertEqual(command[command.index("-progress") + 1], "pipe:1")
|
||||
|
||||
def test_rejects_output_duration_mismatch(self) -> None:
|
||||
input_video = {
|
||||
"codec_type": "video",
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"avg_frame_rate": "24/1",
|
||||
}
|
||||
output_probe = {
|
||||
"format": {"format_name": "mov,mp4,m4a,3gp,3g2,mj2", "duration": "8.9"},
|
||||
"streams": [
|
||||
{
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"avg_frame_rate": "24/1",
|
||||
"duration": "8.9",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
with mock.patch.object(burn, "_probe", return_value=output_probe):
|
||||
with self.assertRaisesRegex(burn.BurnError, "duration changed"):
|
||||
burn._verify_output(
|
||||
"ffprobe",
|
||||
self.root / "output.mp4",
|
||||
input_video,
|
||||
False,
|
||||
input_duration=10.0,
|
||||
)
|
||||
|
||||
def test_end_to_end_burn_layers_subtitles_and_citation_with_receipt(self) -> None:
|
||||
try:
|
||||
ffmpeg, _ = burn._required_executables()
|
||||
burn._require_libass_subtitles_filter(ffmpeg)
|
||||
except burn.BurnError as exc:
|
||||
self.skipTest(str(exc))
|
||||
source = self.root / "source.mp4"
|
||||
generated = subprocess.run(
|
||||
[
|
||||
ffmpeg,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=blue:s=320x180:d=1:r=24",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-y",
|
||||
str(source),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if generated.returncode != 0:
|
||||
self.skipTest(f"could not generate integration fixture: {generated.stderr[-300:]}")
|
||||
self.subtitle.write_text(
|
||||
"[Script Info]\nScriptType: v4.00+\nPlayResX: 320\nPlayResY: 180\n\n"
|
||||
"[V4+ Styles]\n"
|
||||
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, "
|
||||
"BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, "
|
||||
"BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
||||
"Style: Default,Arial,18,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,"
|
||||
"100,100,0,0,1,1,0,2,10,10,10,1\n\n"
|
||||
"[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
||||
"Dialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,Hello\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.write_report()
|
||||
citation_file = self.root / "citation.txt"
|
||||
citation_file.write_text(
|
||||
"内容引自网络,仅供内部交流\n\n"
|
||||
"Authors.\nArticle title.\nJournal. doi:10.3791/test.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
output = self.root / "burned.mp4"
|
||||
|
||||
burn.burn_subtitles(
|
||||
source,
|
||||
self.subtitle,
|
||||
output,
|
||||
preset="ultrafast",
|
||||
citation_file=citation_file,
|
||||
)
|
||||
|
||||
self.assertGreater(output.stat().st_size, 0)
|
||||
self.assertTrue(output.with_suffix(".mp4.citation.json").is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "citation_watermark.py"
|
||||
SPEC = importlib.util.spec_from_file_location("citation_watermark", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
citation = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(citation)
|
||||
|
||||
|
||||
class CitationWatermarkTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
self.manifest = self.root / "download-manifest.json"
|
||||
self.manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"source": {
|
||||
"url": "https://www.jove.com/v/69844/a-protocol-for-harvesting-single-cell-suspension-from-mouse-corneas"
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_attaches_exact_normalized_citation_without_url_by_default(self) -> None:
|
||||
path = citation.attach_citation(
|
||||
self.manifest,
|
||||
"Huang, C., Jiang, N. A Protocol. J. Vis. Exp. doi:10.3791/69844 (2026).",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
path.read_text(encoding="utf-8"),
|
||||
"Huang, C., Jiang, N. A Protocol. J. Vis. Exp. doi:10.3791/69844 (2026).\n",
|
||||
)
|
||||
manifest = json.loads(self.manifest.read_text(encoding="utf-8"))
|
||||
record = manifest["citation_watermark"]
|
||||
self.assertFalse(record["include_source_url"])
|
||||
self.assertEqual(record["position"], "top-left")
|
||||
self.assertEqual(record["citation_sha256"], hashlib.sha256(path.read_bytes()).hexdigest())
|
||||
|
||||
def test_optional_source_url_uses_manifest_canonical_url(self) -> None:
|
||||
path = citation.attach_citation(
|
||||
self.manifest,
|
||||
"Formal citation.",
|
||||
include_source_url=True,
|
||||
)
|
||||
|
||||
self.assertIn("\nSource: https://www.jove.com/v/69844/", path.read_text(encoding="utf-8"))
|
||||
|
||||
def test_structured_citation_and_internal_notice_preserve_four_line_layout(self) -> None:
|
||||
citation_text = (
|
||||
"Huang, C., Jiang, N., Zheng, X., Gu, H., Zhang, L., Ou, S.\n"
|
||||
"A Protocol for Harvesting Single-cell Suspension from Mouse Corneas.\n"
|
||||
"J. Vis. Exp. (230), e69844, doi:10.3791/69844 (2026)."
|
||||
)
|
||||
|
||||
path = citation.attach_citation(
|
||||
self.manifest,
|
||||
citation_text,
|
||||
notice=citation.DEFAULT_INTERNAL_NOTICE,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
path.read_text(encoding="utf-8"),
|
||||
"内容引自网络,仅供内部交流\n\n" + citation_text + "\n",
|
||||
)
|
||||
manifest = json.loads(self.manifest.read_text(encoding="utf-8"))
|
||||
record = manifest["citation_watermark"]
|
||||
self.assertEqual(record["layout"], "notice-plus-three-line-citation")
|
||||
self.assertEqual(record["notice"], citation.DEFAULT_INTERNAL_NOTICE)
|
||||
|
||||
def test_cli_accepts_three_structured_fields_and_default_notice(self) -> None:
|
||||
result = citation.main(
|
||||
[
|
||||
str(self.manifest),
|
||||
"--authors",
|
||||
"Authors",
|
||||
"--title",
|
||||
"Title",
|
||||
"--publication",
|
||||
"Journal. doi:test.",
|
||||
"--notice",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(
|
||||
(self.root / "citation-watermark.txt").read_text(encoding="utf-8"),
|
||||
"内容引自网络,仅供内部交流\n\nAuthors\nTitle\nJournal. doi:test.\n",
|
||||
)
|
||||
|
||||
def test_refuses_signed_or_credential_like_source_url(self) -> None:
|
||||
self.manifest.write_text(
|
||||
json.dumps({"source": {"url": "https://cdn.example/video?token=secret"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(citation.CitationError, "signed or credential"):
|
||||
citation.attach_citation(
|
||||
self.manifest,
|
||||
"Formal citation.",
|
||||
include_source_url=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import argparse
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "ingest_embedded_hls.py"
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
SPEC = importlib.util.spec_from_file_location("ingest_embedded_hls", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
ingest = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(ingest)
|
||||
|
||||
|
||||
class EmbeddedHlsTests(unittest.TestCase):
|
||||
def test_resource_map_must_be_private(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "resources.json"
|
||||
path.write_text(json.dumps({"schema_version": 1}), encoding="utf-8")
|
||||
os.chmod(path, 0o644)
|
||||
with self.assertRaisesRegex(ingest.IngestError, "chmod 600"):
|
||||
ingest._read_private_resource_map(path)
|
||||
|
||||
os.chmod(path, 0o600)
|
||||
self.assertEqual(
|
||||
ingest._read_private_resource_map(path), {"schema_version": 1}
|
||||
)
|
||||
|
||||
def test_private_map_can_be_the_only_job_entry_when_cleanup_is_enabled(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory)
|
||||
resource_map = output / "resource-map.json"
|
||||
resource_map.write_text('{"schema_version": 1}', encoding="utf-8")
|
||||
os.chmod(resource_map, 0o600)
|
||||
|
||||
self.assertEqual(
|
||||
ingest._prepare_output_dir(
|
||||
output,
|
||||
resource_map,
|
||||
resume=False,
|
||||
cleanup_resource_map=True,
|
||||
),
|
||||
output,
|
||||
)
|
||||
with self.assertRaisesRegex(ingest.IngestError, "not empty"):
|
||||
ingest._prepare_output_dir(
|
||||
output,
|
||||
resource_map,
|
||||
resume=False,
|
||||
cleanup_resource_map=False,
|
||||
)
|
||||
|
||||
def test_authorization_query_only_propagates_to_same_origin(self) -> None:
|
||||
root = "https://cdn.example/media/video.m3u8?Policy=secret&Key=pair"
|
||||
same = ingest._authorized_resource_url(
|
||||
"https://cdn.example/media/segment.ts?part=1", root
|
||||
)
|
||||
foreign = ingest._authorized_resource_url(
|
||||
"https://captions.example/subtitle.vtt", root
|
||||
)
|
||||
|
||||
self.assertIn("part=1", same)
|
||||
self.assertIn("Policy=secret", same)
|
||||
self.assertIn("Key=pair", same)
|
||||
self.assertEqual(foreign, "https://captions.example/subtitle.vtt")
|
||||
|
||||
def test_rewrites_segments_and_aes128_key(self) -> None:
|
||||
root = "https://cdn.example/hls/video.m3u8?Policy=secret"
|
||||
source = """#EXTM3U
|
||||
#EXT-X-KEY:METHOD=AES-128,URI="video.key"
|
||||
#EXTINF:10,
|
||||
segment-0.ts
|
||||
#EXT-X-ENDLIST
|
||||
"""
|
||||
rewritten = ingest.rewrite_media_playlist(source, root)
|
||||
|
||||
self.assertIn(
|
||||
'URI="https://cdn.example/hls/video.key?Policy=secret"', rewritten
|
||||
)
|
||||
self.assertIn(
|
||||
"https://cdn.example/hls/segment-0.ts?Policy=secret", rewritten
|
||||
)
|
||||
|
||||
def test_rejects_master_playlist(self) -> None:
|
||||
source = """#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000
|
||||
high.m3u8
|
||||
"""
|
||||
with self.assertRaisesRegex(ingest.IngestError, "master playlist"):
|
||||
ingest.rewrite_media_playlist(source, "https://cdn.example/master.m3u8")
|
||||
|
||||
def test_rejects_drm_protection(self) -> None:
|
||||
source = """#EXTM3U
|
||||
#EXT-X-KEY:METHOD=SAMPLE-AES,URI="key",KEYFORMAT="com.apple.streamingkeydelivery"
|
||||
#EXTINF:10,
|
||||
segment.ts
|
||||
"""
|
||||
with self.assertRaisesRegex(ingest.IngestError, "will not bypass DRM"):
|
||||
ingest.rewrite_media_playlist(source, "https://cdn.example/video.m3u8")
|
||||
|
||||
@unittest.skipUnless(
|
||||
shutil.which("ffmpeg") and shutil.which("ffprobe") and shutil.which("yt-dlp"),
|
||||
"FFmpeg and yt-dlp are required for the local integration test",
|
||||
)
|
||||
def test_local_media_playlist_reaches_standard_translation_stage(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "source"
|
||||
output = root / "output"
|
||||
source.mkdir()
|
||||
output.mkdir()
|
||||
subprocess.run(
|
||||
[
|
||||
shutil.which("ffmpeg") or "ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=blue:s=320x180:r=24:d=2",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-hls_time",
|
||||
"1",
|
||||
"-hls_playlist_type",
|
||||
"vod",
|
||||
str(source / "media.m3u8"),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
(source / "captions.vtt").write_text(
|
||||
"WEBVTT\n\n00:00:00.000 --> 00:00:01.500\nHello from the test video.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
handler = partial(SimpleHTTPRequestHandler, directory=str(source))
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
try:
|
||||
port = server.server_address[1]
|
||||
resource_map = root / "resource-map.json"
|
||||
resource_map.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"page_url": f"http://127.0.0.1:{port}/page",
|
||||
"playlist_url": f"http://127.0.0.1:{port}/media.m3u8?Policy=test",
|
||||
"title": "Embedded test",
|
||||
"id": "fixture",
|
||||
"duration_seconds": 2,
|
||||
"width": 320,
|
||||
"height": 180,
|
||||
"subtitle": {
|
||||
"url": f"http://127.0.0.1:{port}/captions.vtt",
|
||||
"language": "en",
|
||||
"kind": "manual",
|
||||
"label": "English",
|
||||
"format": "vtt",
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(resource_map, 0o600)
|
||||
args = argparse.Namespace(
|
||||
resource_map=resource_map,
|
||||
output_dir=output,
|
||||
target_lang="zh-CN",
|
||||
deliver="full",
|
||||
concurrent_fragments=2,
|
||||
mp4_fallback=False,
|
||||
resume=False,
|
||||
cleanup_resource_map=False,
|
||||
)
|
||||
|
||||
exit_code = ingest.execute(args)
|
||||
|
||||
self.assertEqual(exit_code, 3)
|
||||
manifest = json.loads((output / "download-manifest.json").read_text())
|
||||
self.assertEqual(manifest["status"], "bilingual_required")
|
||||
self.assertTrue((output / "subtitles" / "subtitle-manifest.json").is_file())
|
||||
self.assertEqual(
|
||||
manifest["authentication"]["mode"],
|
||||
"browser-confirmed-resource-map",
|
||||
)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
server_thread.join(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "preflight.py"
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
SPEC = importlib.util.spec_from_file_location("materialsub_preflight", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
preflight = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(preflight)
|
||||
|
||||
|
||||
class PreflightTests(unittest.TestCase):
|
||||
def dependencies(self):
|
||||
return mock.patch.object(
|
||||
preflight.shutil,
|
||||
"which",
|
||||
side_effect=lambda name: f"/bin/{name}" if name != "deno" else None,
|
||||
)
|
||||
|
||||
def test_missing_font_requires_early_decision(self) -> None:
|
||||
with self.dependencies(), mock.patch.object(
|
||||
preflight.burn, "_required_executables", return_value=("/bin/ffmpeg", "/bin/ffprobe")
|
||||
), mock.patch.object(
|
||||
preflight.burn, "_require_libass_subtitles_filter"
|
||||
), mock.patch.object(preflight.burn, "_font_installed", return_value=False):
|
||||
exit_code, result = preflight.assess(
|
||||
font="MiSans", allow_missing_font=False, youtube=False
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 3)
|
||||
self.assertEqual(result["stage"], "font_decision_required")
|
||||
|
||||
def test_missing_font_can_accept_substitution_upfront(self) -> None:
|
||||
with self.dependencies(), mock.patch.object(
|
||||
preflight.burn, "_required_executables", return_value=("/bin/ffmpeg", "/bin/ffprobe")
|
||||
), mock.patch.object(
|
||||
preflight.burn, "_require_libass_subtitles_filter"
|
||||
), mock.patch.object(preflight.burn, "_font_installed", return_value=False):
|
||||
exit_code, result = preflight.assess(
|
||||
font="MiSans", allow_missing_font=True, youtube=True
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertTrue(result["substitution_allowed"])
|
||||
self.assertTrue(any("Deno" in item for item in result["warnings"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,681 @@
|
||||
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()
|
||||
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "verify_delivery.py"
|
||||
SPEC = importlib.util.spec_from_file_location("verify_delivery", SCRIPT)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
delivery = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(delivery)
|
||||
|
||||
|
||||
class VerifyDeliveryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temporary.name)
|
||||
self.source = self.root / "video.intermediate.mkv"
|
||||
self.source.write_bytes(b"video")
|
||||
self.subtitle = self.root / "video.source-srt.en.srt"
|
||||
self.subtitle.write_text("1\n00:00:00,000 --> 00:00:01,000\nHello\n")
|
||||
self.manifest = self.root / "download-manifest.json"
|
||||
self.manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "downloaded",
|
||||
"output_directory": str(self.root),
|
||||
"artifacts": {
|
||||
"intermediate": {"path": self.source.name},
|
||||
"subtitle": {
|
||||
"source_srt": {"path": self.subtitle.name},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def test_subtitled_job_with_only_translation_inputs_is_incomplete(self) -> None:
|
||||
inputs = self.root / "subtitles" / "translation-input"
|
||||
inputs.mkdir(parents=True)
|
||||
batch = inputs / "batch-0001.json"
|
||||
batch.write_text("{}", encoding="utf-8")
|
||||
outputs = self.root / "subtitles" / "translation-output"
|
||||
outputs.mkdir()
|
||||
(self.root / "subtitles" / "subtitle-manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"translation_batches": [{"path": str(batch)}],
|
||||
"translation_output_dir": str(outputs),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = delivery.assess_delivery(self.manifest)
|
||||
|
||||
self.assertFalse(result["complete"])
|
||||
self.assertEqual(result["stage"], "translation_required")
|
||||
self.assertIn("batch-0001.json", result["missing"])
|
||||
|
||||
def test_video_deliverable_completes_with_untranslated_subtitle(self) -> None:
|
||||
self.manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "video_complete",
|
||||
"deliverable": "video",
|
||||
"output_directory": str(self.root),
|
||||
"artifacts": {
|
||||
"intermediate": {"path": self.source.name},
|
||||
"subtitle": {
|
||||
"language": "en",
|
||||
"source_srt": {"path": self.subtitle.name},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = delivery.assess_delivery(self.manifest)
|
||||
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["stage"], "video_complete")
|
||||
|
||||
def test_subs_deliverable_needs_no_video_file(self) -> None:
|
||||
self.source.unlink()
|
||||
self.manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "subs_complete",
|
||||
"deliverable": "subs",
|
||||
"output_directory": str(self.root),
|
||||
"artifacts": {
|
||||
"intermediate": None,
|
||||
"subtitle": {
|
||||
"language": "en",
|
||||
"source_srt": {"path": self.subtitle.name},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = delivery.assess_delivery(self.manifest)
|
||||
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["stage"], "subs_complete")
|
||||
|
||||
def test_bilingual_subs_deliverable_stops_at_render(self) -> None:
|
||||
inputs = self.root / "subtitles" / "translation-input"
|
||||
inputs.mkdir(parents=True)
|
||||
batch = inputs / "batch-0001.json"
|
||||
batch.write_text("{}", encoding="utf-8")
|
||||
outputs = self.root / "subtitles" / "translation-output"
|
||||
outputs.mkdir()
|
||||
(outputs / "batch-0001.json").write_text("{}", encoding="utf-8")
|
||||
(self.root / "subtitles" / "subtitle-manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"translation_batches": [{"path": str(batch)}],
|
||||
"translation_output_dir": str(outputs),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "bilingual_required",
|
||||
"deliverable": "bilingual-subs",
|
||||
"output_directory": str(self.root),
|
||||
"artifacts": {
|
||||
"intermediate": None,
|
||||
"subtitle": {
|
||||
"language": "en",
|
||||
"source_srt": {"path": self.subtitle.name},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
pending = delivery.assess_delivery(self.manifest)
|
||||
self.assertFalse(pending["complete"])
|
||||
self.assertEqual(pending["stage"], "render_required")
|
||||
|
||||
rendered = self.root / "subtitles" / "rendered"
|
||||
rendered.mkdir()
|
||||
(rendered / "bilingual.ass").write_text("[Script Info]\n", encoding="utf-8")
|
||||
(rendered / "validation.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
result = delivery.assess_delivery(self.manifest)
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["stage"], "bilingual_subs_complete")
|
||||
|
||||
def test_full_delivery_uses_manifest_bilingual_filename(self) -> None:
|
||||
inputs = self.root / "subtitles" / "translation-input"
|
||||
inputs.mkdir(parents=True)
|
||||
batch = inputs / "batch-0001.json"
|
||||
batch.write_text("{}", encoding="utf-8")
|
||||
outputs = self.root / "subtitles" / "translation-output"
|
||||
outputs.mkdir()
|
||||
(outputs / "batch-0001.json").write_text("{}", encoding="utf-8")
|
||||
(self.root / "subtitles" / "subtitle-manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"translation_batches": [{"path": str(batch)}],
|
||||
"translation_output_dir": str(outputs),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
rendered = self.root / "subtitles" / "rendered"
|
||||
rendered.mkdir()
|
||||
(rendered / "bilingual.ass").write_text("[Script Info]\n", encoding="utf-8")
|
||||
(rendered / "validation.json").write_text("{}", encoding="utf-8")
|
||||
expected = self.root / "双语字幕版「测试视频」.mp4"
|
||||
self.manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "bilingual_required",
|
||||
"deliverable": "full",
|
||||
"output_directory": str(self.root),
|
||||
"delivery_names": {
|
||||
"cover": "封面.jpg",
|
||||
"bilingual_video": expected.name,
|
||||
},
|
||||
"artifacts": {
|
||||
"intermediate": {"path": self.source.name},
|
||||
"subtitle": {
|
||||
"language": "en",
|
||||
"source_srt": {"path": self.subtitle.name},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(self.root / "legacy.bilingual.mp4").write_bytes(b"legacy")
|
||||
|
||||
pending = delivery.assess_delivery(self.manifest)
|
||||
self.assertFalse(pending["complete"])
|
||||
self.assertEqual(pending["missing"], [str(expected.resolve())])
|
||||
|
||||
expected.write_bytes(b"burned")
|
||||
result = delivery.assess_delivery(self.manifest)
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["burned_video"], str(expected.resolve()))
|
||||
|
||||
def test_declared_citation_requires_matching_burn_receipt(self) -> None:
|
||||
inputs = self.root / "subtitles" / "translation-input"
|
||||
inputs.mkdir(parents=True)
|
||||
batch = inputs / "batch-0001.json"
|
||||
batch.write_text("{}", encoding="utf-8")
|
||||
outputs = self.root / "subtitles" / "translation-output"
|
||||
outputs.mkdir()
|
||||
(outputs / "batch-0001.json").write_text("{}", encoding="utf-8")
|
||||
(self.root / "subtitles" / "subtitle-manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"translation_batches": [{"path": str(batch)}],
|
||||
"translation_output_dir": str(outputs),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
rendered = self.root / "subtitles" / "rendered"
|
||||
rendered.mkdir()
|
||||
(rendered / "bilingual.ass").write_text("[Script Info]\n", encoding="utf-8")
|
||||
(rendered / "validation.json").write_text("{}", encoding="utf-8")
|
||||
expected = self.root / "双语字幕版「测试视频」.mp4"
|
||||
expected.write_bytes(b"burned")
|
||||
citation_file = self.root / "citation-watermark.txt"
|
||||
citation_file.write_text("Formal citation.\n", encoding="utf-8")
|
||||
citation_hash = hashlib.sha256(citation_file.read_bytes()).hexdigest()
|
||||
manifest_value = {
|
||||
"deliverable": "full",
|
||||
"output_directory": str(self.root),
|
||||
"delivery_names": {"bilingual_video": expected.name},
|
||||
"citation_watermark": {
|
||||
"enabled": True,
|
||||
"citation_file": citation_file.name,
|
||||
"citation_sha256": citation_hash,
|
||||
"position": "top-left",
|
||||
},
|
||||
"artifacts": {
|
||||
"intermediate": {"path": self.source.name},
|
||||
"subtitle": {"source_srt": {"path": self.subtitle.name}},
|
||||
},
|
||||
}
|
||||
self.manifest.write_text(json.dumps(manifest_value), encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(delivery.DeliveryError, "burn receipt is missing"):
|
||||
delivery.assess_delivery(self.manifest)
|
||||
|
||||
receipt = expected.with_suffix(expected.suffix + ".citation.json")
|
||||
receipt.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"output_file": expected.name,
|
||||
"output_sha256": hashlib.sha256(expected.read_bytes()).hexdigest(),
|
||||
"citation_sha256": citation_hash,
|
||||
"position": "top-left",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = delivery.assess_delivery(self.manifest)
|
||||
self.assertTrue(result["complete"])
|
||||
|
||||
expected.write_bytes(b"changed")
|
||||
with self.assertRaisesRegex(delivery.DeliveryError, "output_sha256"):
|
||||
delivery.assess_delivery(self.manifest)
|
||||
|
||||
def test_no_dialogue_subtitle_counts_as_video_only(self) -> None:
|
||||
self.manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "video_only_complete",
|
||||
"output_directory": str(self.root),
|
||||
"artifacts": {
|
||||
"intermediate": {"path": self.source.name},
|
||||
"subtitle": {
|
||||
"language": "en",
|
||||
"dialogue": False,
|
||||
"source_srt": {"path": self.subtitle.name},
|
||||
},
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = delivery.assess_delivery(self.manifest)
|
||||
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["stage"], "video_only_complete")
|
||||
|
||||
def test_video_only_job_requires_the_video_file_on_disk(self) -> None:
|
||||
self.manifest.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "downloaded",
|
||||
"output_directory": str(self.root),
|
||||
"artifacts": {
|
||||
"intermediate": {"path": self.source.name},
|
||||
"subtitle": None,
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = delivery.assess_delivery(self.manifest)
|
||||
self.assertTrue(result["complete"])
|
||||
self.assertEqual(result["stage"], "video_only_complete")
|
||||
|
||||
self.source.unlink()
|
||||
with self.assertRaisesRegex(delivery.DeliveryError, "video artifact"):
|
||||
delivery.assess_delivery(self.manifest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user