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()