Add Avisynth video encoding workflow

This commit is contained in:
ajp_anton
2026-07-21 00:41:00 +00:00
parent f50f465465
commit 49296d6f45
37 changed files with 9487 additions and 76 deletions
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch
from tools.exiftool import run_exiftool_command
class ExiftoolInvocationTests(unittest.TestCase):
def test_windows_uses_utf8_argument_file_for_unicode_paths(self) -> None:
captured: dict[str, object] = {}
def fake_run(command, **kwargs):
captured["command"] = command
argument_file = Path(kwargs["cwd"]) / command[-1]
captured["contents"] = argument_file.read_text(encoding="utf-8")
return subprocess.CompletedProcess(command, 0)
with (
patch("tools.exiftool._use_windows_argument_file", return_value=True),
patch("tools.exiftool.subprocess.run", side_effect=fake_run),
):
run_exiftool_command(
"exiftool.exe",
["-j", "C:\\Photos\\M\u00e4rchen.jpg"],
check=True,
)
self.assertEqual(
captured["command"],
["exiftool.exe", "-charset", "filename=UTF8", "-@", "arguments.txt"],
)
self.assertEqual(captured["contents"], "-j\nC:\\Photos\\M\u00e4rchen.jpg\n")
if __name__ == "__main__":
unittest.main()
+427
View File
@@ -0,0 +1,427 @@
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()
File diff suppressed because it is too large Load Diff