Files
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

334 lines
12 KiB
Python

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