428 lines
16 KiB
Python
428 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
WORKSPACE = REPO / "video_workspace"
|
|
FIXTURES = REPO / "tests"
|
|
STATIC_FFMPEG_DIR = Path("/tmp/media_batch_video_tools/ffmpeg-7.0.2-amd64-static")
|
|
AVISYNTH_LIB_DIR = Path("/tmp/media_batch_avisynth/install/lib")
|
|
FFMS2_LIB_DIR = Path("/tmp/media_batch_ffms2_pkg/root/usr/lib/x86_64-linux-gnu")
|
|
FFMS2_PLUGIN = Path("/tmp/media_batch_ffms2_avs_build_rc2/ffms2_avisynth_hidden.so")
|
|
AVS_RUNNER = REPO / "tools" / "avisynth_runner" / "mbt_avs_runner"
|
|
X264 = Path("/tmp/mbt-x264-build/x264")
|
|
MKVMERGE = Path("/tmp/mbt-video-mux-tools/bin/mkvmerge")
|
|
|
|
|
|
def scenario_environment() -> dict[str, str]:
|
|
env = os.environ.copy()
|
|
env["PATH"] = (
|
|
f"{STATIC_FFMPEG_DIR}:{X264.parent}:"
|
|
f"{MKVMERGE.parent}:{env.get('PATH', '')}"
|
|
)
|
|
env["LD_LIBRARY_PATH"] = (
|
|
f"{AVISYNTH_LIB_DIR}:{FFMS2_LIB_DIR}:"
|
|
f"{env.get('LD_LIBRARY_PATH', '')}"
|
|
)
|
|
env["MBT_FFMS2_PLUGIN"] = str(FFMS2_PLUGIN)
|
|
env["MBT_AVS_RUNNER"] = str(AVS_RUNNER)
|
|
env["MBT_VIDEO_FINAL_ACTION"] = "leave"
|
|
env["MBT_VIDEO_TIMESTAMP_NAMES"] = "0"
|
|
env["MBT_VIDEO_CODEC"] = "x264"
|
|
env["MBT_VIDEO_CRF"] = "35"
|
|
env["MBT_VIDEO_PRESET"] = "ultrafast"
|
|
return env
|
|
|
|
|
|
def require_scenario_prerequisites() -> dict[str, str]:
|
|
missing: list[str] = []
|
|
if not AVS_RUNNER.is_file():
|
|
missing.append(str(AVS_RUNNER))
|
|
if not FFMS2_PLUGIN.is_file():
|
|
missing.append(str(FFMS2_PLUGIN))
|
|
if not X264.is_file():
|
|
missing.append(str(X264))
|
|
if not MKVMERGE.is_file():
|
|
missing.append(str(MKVMERGE))
|
|
if not STATIC_FFMPEG_DIR.is_dir():
|
|
missing.append(str(STATIC_FFMPEG_DIR))
|
|
if not AVISYNTH_LIB_DIR.is_dir():
|
|
missing.append(str(AVISYNTH_LIB_DIR))
|
|
if not FFMS2_LIB_DIR.is_dir():
|
|
missing.append(str(FFMS2_LIB_DIR))
|
|
for tool in ("ffmpeg", "ffprobe", "exiftool", "x264", "mkvmerge"):
|
|
if shutil.which(tool, path=scenario_environment()["PATH"]) is None:
|
|
missing.append(tool)
|
|
if missing:
|
|
raise unittest.SkipTest(
|
|
"video end-to-end prerequisites are unavailable: "
|
|
+ ", ".join(missing)
|
|
)
|
|
return scenario_environment()
|
|
|
|
|
|
def ffprobe_json(path: Path, env: dict[str, str]) -> dict:
|
|
completed = subprocess.run(
|
|
[
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-show_streams",
|
|
"-show_format",
|
|
"-of",
|
|
"json",
|
|
str(path),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
def ffprobe_frame_timestamps(path: Path, env: dict[str, str]) -> list[float]:
|
|
completed = subprocess.run(
|
|
[
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-select_streams",
|
|
"v:0",
|
|
"-show_entries",
|
|
"frame=best_effort_timestamp_time",
|
|
"-of",
|
|
"json",
|
|
str(path),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
data = json.loads(completed.stdout)
|
|
timestamps: list[float] = []
|
|
for frame in data.get("frames", []):
|
|
value = frame.get("best_effort_timestamp_time")
|
|
if value is not None:
|
|
timestamps.append(float(value))
|
|
return timestamps
|
|
|
|
|
|
def exiftool_json(path: Path, env: dict[str, str]) -> dict:
|
|
completed = subprocess.run(
|
|
["exiftool", "-json", "-a", "-u", "-G0:1", str(path)],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
return json.loads(completed.stdout)[0]
|
|
|
|
|
|
class VideoEncodeEndToEndTests(unittest.TestCase):
|
|
maxDiff = None
|
|
|
|
def setUp(self) -> None:
|
|
self.env = require_scenario_prerequisites()
|
|
if WORKSPACE.exists():
|
|
shutil.rmtree(WORKSPACE)
|
|
|
|
def tearDown(self) -> None:
|
|
if WORKSPACE.exists():
|
|
shutil.rmtree(WORKSPACE)
|
|
|
|
def copy_fixture(self, fixture_name: str, temp_root: Path) -> Path:
|
|
source = FIXTURES / fixture_name
|
|
if not source.is_file():
|
|
raise unittest.SkipTest(f"fixture missing: {source}")
|
|
target = temp_root / fixture_name
|
|
shutil.copy2(source, target)
|
|
return target
|
|
|
|
def create_workspace(self, source: Path) -> Path:
|
|
self.run_video_encode(source, encode=False)
|
|
script = WORKSPACE / f"{source.name}.avs"
|
|
self.assertTrue(script.is_file(), script)
|
|
return script
|
|
|
|
def run_video_encode(
|
|
self,
|
|
source: Path,
|
|
*,
|
|
encode: bool,
|
|
extra_env: dict[str, str] | None = None,
|
|
) -> str:
|
|
env = self.env.copy()
|
|
env["MBT_ENCODE_VIDEO"] = "1" if encode else "0"
|
|
if extra_env:
|
|
env.update(extra_env)
|
|
completed = subprocess.run(
|
|
["python3", "video_encode.py", str(source)],
|
|
cwd=REPO,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
return completed.stdout
|
|
|
|
def replace_user_script_body(self, script: Path, body: str) -> None:
|
|
marker = "# User edits below this line. The imported source clip will already be in \"last\"."
|
|
text = script.read_text(encoding="utf-8")
|
|
self.assertIn(marker, text)
|
|
prefix = text.split(marker, 1)[0] + marker + "\n\n"
|
|
script.write_text(prefix + body.strip() + "\n", encoding="utf-8")
|
|
|
|
def assert_video_stream_frames(self, output: Path, expected: int) -> None:
|
|
data = ffprobe_json(output, self.env)
|
|
video_stream = next(
|
|
stream for stream in data["streams"] if stream["codec_type"] == "video"
|
|
)
|
|
frame_count = int(video_stream.get("nb_frames") or len(ffprobe_frame_timestamps(output, self.env)))
|
|
self.assertEqual(frame_count, expected)
|
|
|
|
def assert_audio_codec(self, output: Path, expected: str) -> None:
|
|
data = ffprobe_json(output, self.env)
|
|
audio_stream = next(
|
|
stream for stream in data["streams"] if stream["codec_type"] == "audio"
|
|
)
|
|
self.assertEqual(audio_stream["codec_name"], expected)
|
|
|
|
def test_real_frame_validation_mode_renders_the_source(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("20260602_222842.mp4", Path(temp_dir))
|
|
self.create_workspace(source)
|
|
validation_script = WORKSPACE / ".source" / f"{source.name}.validate.avs"
|
|
validation_csv = WORKSPACE / ".source" / f"{source.name}.real.frames.csv"
|
|
completed = subprocess.run(
|
|
[str(AVS_RUNNER), "--validate-real", str(validation_script), str(validation_csv)],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
env=self.env,
|
|
)
|
|
|
|
self.assertIn("rendered=170", completed.stdout)
|
|
self.assertEqual(len(validation_csv.read_text(encoding="utf-8").splitlines()), 171)
|
|
|
|
def test_cfr_mp4_no_edit_copies_metadata(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
|
self.create_workspace(source)
|
|
self.run_video_encode(
|
|
source,
|
|
encode=True,
|
|
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
|
|
)
|
|
|
|
output = WORKSPACE / "C0011.MP4.mp4"
|
|
self.assertTrue(output.is_file(), output)
|
|
self.assert_video_stream_frames(output, 60)
|
|
self.assert_audio_codec(output, "aac")
|
|
self.assertFalse(output.with_name(f"{output.name}.manifest.json").exists())
|
|
self.assertFalse((WORKSPACE / ".source" / "avisynth_helpers.avs").exists())
|
|
self.assertFalse(list((WORKSPACE / ".source").rglob("*.frames.csv")))
|
|
self.assertFalse(list((WORKSPACE / ".source").rglob("*.runner.log")))
|
|
|
|
def test_vfr_mp4_trim_preserves_location_and_adjusts_end_timestamp(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("20260602_222842.mp4", Path(temp_dir))
|
|
script = self.create_workspace(source)
|
|
self.replace_user_script_body(script, "Trim(last, 0, 84)\nlast")
|
|
self.run_video_encode(
|
|
source,
|
|
encode=True,
|
|
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
|
|
)
|
|
|
|
output = WORKSPACE / "20260602_222842.mp4.mp4"
|
|
self.assertTrue(output.is_file(), output)
|
|
self.assert_video_stream_frames(output, 85)
|
|
metadata = exiftool_json(output, self.env)
|
|
self.assertIn("Composite:GPSLatitude", metadata)
|
|
self.assertIn("Composite:GPSLongitude", metadata)
|
|
self.assertEqual(metadata.get("QuickTime:CreateDate"), "2026:06:02 13:28:46")
|
|
|
|
def test_vfr_mp4_x265_uses_post_encode_timecodes(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("20260602_222842.mp4", Path(temp_dir))
|
|
script = self.create_workspace(source)
|
|
self.replace_user_script_body(script, "Trim(last, 0, 84)\nlast")
|
|
self.run_video_encode(
|
|
source,
|
|
encode=True,
|
|
extra_env={
|
|
"MBT_VIDEO_CONTAINER": "mp4",
|
|
"MBT_VIDEO_CODEC": "x265",
|
|
"MBT_VIDEO_AUDIO": "aac",
|
|
},
|
|
)
|
|
|
|
output = WORKSPACE / "20260602_222842.mp4.mp4"
|
|
self.assertTrue(output.is_file(), output)
|
|
self.assert_video_stream_frames(output, 85)
|
|
|
|
def test_select_even_uses_segmented_aac_audio(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
|
script = self.create_workspace(source)
|
|
self.replace_user_script_body(script, "SelectEven(last)\nlast")
|
|
self.run_video_encode(
|
|
source,
|
|
encode=True,
|
|
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
|
|
)
|
|
|
|
output = WORKSPACE / "C0011.MP4.mp4"
|
|
self.assertTrue(output.is_file(), output)
|
|
self.assert_video_stream_frames(output, 30)
|
|
self.assert_audio_codec(output, "aac")
|
|
|
|
def test_mixed_normal_delete_and_drop_frame_preserves_drop_durations(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
|
script = self.create_workspace(source)
|
|
self.replace_user_script_body(
|
|
script,
|
|
"""
|
|
SelectEvery(last, 4, 0, 2, 3)
|
|
MBT_DropEvery(last, 3, 2)
|
|
last
|
|
""",
|
|
)
|
|
self.run_video_encode(
|
|
source,
|
|
encode=True,
|
|
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
|
|
)
|
|
|
|
output = WORKSPACE / "C0011.MP4.mp4"
|
|
self.assertTrue(output.is_file(), output)
|
|
self.assert_video_stream_frames(output, 30)
|
|
|
|
def test_drop_frame_range_helper(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
|
script = self.create_workspace(source)
|
|
self.replace_user_script_body(script, "MBT_Drop(last, 1, 2)\nlast")
|
|
self.run_video_encode(
|
|
source,
|
|
encode=True,
|
|
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
|
|
)
|
|
|
|
output = WORKSPACE / "C0011.MP4.mp4"
|
|
self.assertTrue(output.is_file(), output)
|
|
self.assert_video_stream_frames(output, 58)
|
|
|
|
def test_rotate_crop_helper_crops_a_full_frame_turn(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-rotate-crop-") as temp_dir:
|
|
temp = Path(temp_dir)
|
|
helper = (REPO / "tools" / "avisynth_helpers.avs").as_posix()
|
|
cases = [
|
|
("source-dar", "RotateCrop(last, 10.0)", "width=1484,height=832"),
|
|
("square", "RotateCrop(last, 10.0, 1.0)", "width=932,height=932"),
|
|
]
|
|
for name, call, expected in cases:
|
|
script = temp / f"{name}.avs"
|
|
csv = temp / f"{name}.csv"
|
|
script.write_text(
|
|
"\n".join(
|
|
[
|
|
'function Turn(clip c, float "angle", int "lx", int "rx", int "ty", int "by")',
|
|
"{",
|
|
' Assert(!Defined(lx) && !Defined(rx) && !Defined(ty) && !Defined(by), "RotateCrop must not limit Turn\'s display rectangle")',
|
|
" return c",
|
|
"}",
|
|
f'Import("{helper}")',
|
|
'BlankClip(width=1920, height=1080, pixel_type="YV12")',
|
|
call,
|
|
]
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
completed = subprocess.run(
|
|
[str(AVS_RUNNER), "--validate", str(script), str(csv)],
|
|
capture_output=True,
|
|
text=True,
|
|
env=self.env,
|
|
)
|
|
self.assertEqual(completed.returncode, 0, completed.stderr)
|
|
self.assertIn(expected, completed.stdout)
|
|
|
|
def test_simple_trim_can_copy_audio(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
|
script = self.create_workspace(source)
|
|
self.replace_user_script_body(script, "Trim(last, 10, 49)\nlast")
|
|
self.run_video_encode(
|
|
source,
|
|
encode=True,
|
|
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "copy"},
|
|
)
|
|
|
|
output = WORKSPACE / "C0011.MP4.mp4"
|
|
self.assertTrue(output.is_file(), output)
|
|
self.assert_video_stream_frames(output, 40)
|
|
self.assert_audio_codec(output, "pcm_s16be")
|
|
|
|
def test_mkv_opus_output(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
|
self.create_workspace(source)
|
|
self.run_video_encode(
|
|
source,
|
|
encode=True,
|
|
extra_env={"MBT_VIDEO_CONTAINER": "mkv", "MBT_VIDEO_AUDIO": "opus"},
|
|
)
|
|
|
|
output = WORKSPACE / "C0011.MP4.mkv"
|
|
self.assertTrue(output.is_file(), output)
|
|
self.assert_video_stream_frames(output, 60)
|
|
self.assert_audio_codec(output, "opus")
|
|
|
|
def test_existing_valid_editable_script_is_kept(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
|
script = self.create_workspace(source)
|
|
original_text = script.read_text(encoding="utf-8")
|
|
output = self.run_video_encode(source, encode=False)
|
|
|
|
self.assertIn("existing editable scripts", output)
|
|
self.assertEqual(script.read_text(encoding="utf-8"), original_text)
|
|
|
|
def test_existing_invalid_editable_script_is_rejected_noninteractive(self) -> None:
|
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
|
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
|
script = self.create_workspace(source)
|
|
script.write_text(
|
|
'BlankClip(length=1, width=16, height=16, pixel_type="YV12")\n',
|
|
encoding="utf-8",
|
|
)
|
|
env = self.env.copy()
|
|
env["MBT_ENCODE_VIDEO"] = "0"
|
|
completed = subprocess.run(
|
|
["python3", "video_encode.py", str(source)],
|
|
cwd=REPO,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
|
|
self.assertNotEqual(completed.returncode, 0)
|
|
self.assertIn("invalid kept script", completed.stdout)
|
|
self.assertIn("not overwritten", completed.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|