e27cc22cfb
Derived from pengchujin/jzsub at 222a90265d2a8797ca258eb1a980cee0863a8311; preserve the upstream MIT license and attribution.
334 lines
12 KiB
Python
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()
|