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()
|
||||
Reference in New Issue
Block a user