1609 lines
59 KiB
Python
1609 lines
59 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
from dataclasses import replace
|
|
from datetime import datetime, timedelta, timezone
|
|
from io import StringIO
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from tools.avisynth_render import AvisynthClipInfo, run_validation_script
|
|
from tools.avisynth_runner import (
|
|
script_requests_32bit,
|
|
validate_runner_path,
|
|
)
|
|
from tools.avisynth_validate import FrameIdentity, read_frame_identity_csv
|
|
from tools.avisynth_workspace import (
|
|
_clear_source_root,
|
|
_format_relative_timestamp,
|
|
_hidden_source_script,
|
|
)
|
|
from tools.executables import require_executable
|
|
from tools.video_encode_output import (
|
|
AudioEncodeOptions,
|
|
VideoCodecOptions,
|
|
VideoEncodeRequest,
|
|
add_dynamic_hdr10plus_metadata,
|
|
add_x265_color_metadata,
|
|
add_video_threads,
|
|
atempo_filters,
|
|
choose_video_pixel_format,
|
|
encoder_statistics_lines,
|
|
read_ffmpeg_progress,
|
|
speed_change_filters,
|
|
x265_parameter_path,
|
|
)
|
|
from tools.hdr10plus import select_hdr10plus_frames
|
|
from tools.video_color import ColorMetadata
|
|
from tools.video_timestamp_encode import write_timecode_v2, x264_command
|
|
from tools.video_encode_plan import (
|
|
has_avisynth_speed_changes,
|
|
normal_deleted_frame_count,
|
|
preserves_entire_source_timeline,
|
|
y4m_command_for_timeline,
|
|
)
|
|
from tools.video_codec_constraints import (
|
|
VideoConstraintSpec,
|
|
available_levels,
|
|
available_profiles,
|
|
minimum_level,
|
|
minimum_profile,
|
|
resolve_level,
|
|
resolve_profile,
|
|
)
|
|
from tools.video_inputs import VideoInput
|
|
from tools.video_outputs import (
|
|
EncodedItem,
|
|
OutputNamingOptions,
|
|
move_encoded_output_to_source_dir,
|
|
output_begin_timestamp,
|
|
output_end_timestamp,
|
|
planned_output_path,
|
|
)
|
|
from tools.video_batch_encode import (
|
|
EncodingAnswers,
|
|
EncodingOutputSpec,
|
|
_default_output_timezone,
|
|
_default_answer,
|
|
_environment_encoding_settings,
|
|
_encoding_steps,
|
|
_parse_audio,
|
|
_parse_answer,
|
|
_question_for_key,
|
|
_stream_copy_warnings,
|
|
)
|
|
from tools.video_probe import (
|
|
AudioStreamProbe,
|
|
VideoProbe,
|
|
_hdr_side_data,
|
|
_format_mastering_display,
|
|
_video_timezone,
|
|
hdr_kind,
|
|
summarize_frame_timing,
|
|
)
|
|
from tools.video_reporting import _format_probe_timestamp, _format_vfr_timing
|
|
from tools.video_reporting import _format_source_span, format_validation_summary_lines
|
|
from tools.console import PipelineProgressView, ProgressView, _progress_bar, stream_is_interactive
|
|
from tools.video_encode_output import _update_renderer_progress
|
|
from tools.video_timeline import OutputFrameTiming, OutputTimeline, build_output_timeline
|
|
from video_encode import (
|
|
print_workspace_summary,
|
|
stale_workspace_manifests,
|
|
wait_for_script_edits,
|
|
)
|
|
|
|
|
|
def make_probe(
|
|
*,
|
|
timestamps: list[float],
|
|
duration: float,
|
|
metadata_end: datetime | None = None,
|
|
source_timezone: timezone | None = None,
|
|
) -> VideoProbe:
|
|
return VideoProbe(
|
|
path=Path("source.mp4"),
|
|
codec="h264",
|
|
width=1920,
|
|
height=1080,
|
|
pixel_format="yuv420p",
|
|
bit_depth=8,
|
|
video_bit_rate=10_000_000,
|
|
color_space="bt709",
|
|
color_matrix="Rec709",
|
|
expected_color_matrix="Rec709",
|
|
duration_seconds=duration,
|
|
frame_timestamps=timestamps,
|
|
stream_frame_count=len(timestamps),
|
|
audio_streams=[AudioStreamProbe("aac", 2, 48000, 192_000)],
|
|
audio_stream_count=1,
|
|
audio_sample_rate=48000,
|
|
avg_frame_rate="1/1",
|
|
r_frame_rate="1/1",
|
|
time_base="1/1000",
|
|
timing=summarize_frame_timing(timestamps),
|
|
metadata_end=metadata_end,
|
|
metadata_begin_tag=None,
|
|
source_timezone=source_timezone,
|
|
computed_begin=(
|
|
metadata_end - timedelta(seconds=duration)
|
|
if metadata_end is not None
|
|
else None
|
|
),
|
|
filename_begin=(
|
|
metadata_end - timedelta(seconds=round(duration))
|
|
if metadata_end is not None
|
|
else None
|
|
),
|
|
gps_latitude=None,
|
|
gps_longitude=None,
|
|
warnings=[],
|
|
)
|
|
|
|
|
|
def make_answers(**values: object) -> EncodingAnswers:
|
|
answers = EncodingAnswers(timezone.utc, True)
|
|
for key, value in values.items():
|
|
answers.set(key, value)
|
|
return answers
|
|
|
|
|
|
class VideoCodecConstraintTests(unittest.TestCase):
|
|
def test_video_copy_is_only_available_for_a_full_source_timeline(self) -> None:
|
|
source = OutputTimeline(
|
|
frames=[OutputFrameTiming(index, 1, index, float(index), 1.0) for index in range(3)],
|
|
audio_segments=[(0.0, 3.0)],
|
|
consumed_source_frames=[0, 1, 2],
|
|
duration_seconds=3.0,
|
|
timing_kind="cfr",
|
|
beginning_trimmed=False,
|
|
ending_trimmed=False,
|
|
dropped_frame_count=0,
|
|
source_frame_count=3,
|
|
matrix=None,
|
|
)
|
|
trimmed = OutputTimeline(
|
|
frames=source.frames[:2],
|
|
audio_segments=[(0.0, 2.0)],
|
|
consumed_source_frames=[0, 1],
|
|
duration_seconds=2.0,
|
|
timing_kind="cfr",
|
|
beginning_trimmed=False,
|
|
ending_trimmed=True,
|
|
dropped_frame_count=0,
|
|
source_frame_count=3,
|
|
matrix=None,
|
|
)
|
|
|
|
self.assertTrue(preserves_entire_source_timeline(source))
|
|
self.assertFalse(preserves_entire_source_timeline(trimmed))
|
|
|
|
def test_mp4_stream_copy_warnings_name_incompatible_streams(self) -> None:
|
|
specs = [
|
|
EncodingOutputSpec(
|
|
label="clip.mkv",
|
|
width=1920,
|
|
height=1080,
|
|
bit_depth=8,
|
|
chroma="420",
|
|
fps=30.0,
|
|
begin_utc="unknown",
|
|
summary_lines=(),
|
|
video_codec="prores",
|
|
audio_codecs=("pcm_s16le",),
|
|
)
|
|
]
|
|
|
|
warnings = _stream_copy_warnings(
|
|
extension=".mp4",
|
|
video_codec="copy",
|
|
audio_mode="copy",
|
|
specs=specs,
|
|
)
|
|
|
|
self.assertEqual(
|
|
warnings,
|
|
["does not support prores video", "does not support pcm_s16le audio"],
|
|
)
|
|
|
|
def test_mp4_allows_opus_and_flac_audio(self) -> None:
|
|
self.assertEqual(_parse_audio("opus"), "opus")
|
|
self.assertEqual(_parse_audio("flac"), "flac")
|
|
spec = EncodingOutputSpec(
|
|
label="clip.mp4",
|
|
width=1920,
|
|
height=1080,
|
|
bit_depth=8,
|
|
chroma="420",
|
|
fps=30.0,
|
|
begin_utc="unknown",
|
|
summary_lines=(),
|
|
video_codec="h264",
|
|
audio_codecs=("flac",),
|
|
)
|
|
|
|
self.assertEqual(
|
|
_stream_copy_warnings(
|
|
extension=".mp4",
|
|
video_codec="libx265",
|
|
audio_mode="copy",
|
|
specs=[spec],
|
|
),
|
|
[],
|
|
)
|
|
|
|
def test_pixel_format_preserves_ten_bit_output(self) -> None:
|
|
formats = [
|
|
(8, "yuv420p"),
|
|
(10, "yuv420p10le"),
|
|
(12, "yuv420p12le"),
|
|
]
|
|
with patch(
|
|
"tools.video_encode_output.supported_pixel_formats",
|
|
return_value=formats,
|
|
):
|
|
selected = choose_video_pixel_format(
|
|
Path("ffmpeg"),
|
|
"libx265",
|
|
10,
|
|
"420",
|
|
)
|
|
|
|
self.assertEqual(selected, "yuv420p10le")
|
|
|
|
def test_x265_color_metadata_sets_hdr_vui_parameters(self) -> None:
|
|
command: list[str] = []
|
|
|
|
add_x265_color_metadata(
|
|
command,
|
|
ColorMetadata(9, 9, 16, 0),
|
|
)
|
|
|
|
self.assertEqual(
|
|
command,
|
|
[
|
|
"-x265-params",
|
|
"colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:range=limited",
|
|
],
|
|
)
|
|
|
|
def test_mkvmerge_color_options_carry_hdr_signal(self) -> None:
|
|
self.assertEqual(
|
|
ColorMetadata(9, 9, 16, 0).mkvmerge_args(),
|
|
[
|
|
"--color-matrix-coefficients", "0:9",
|
|
"--color-primaries", "0:9",
|
|
"--color-transfer-characteristics", "0:16",
|
|
"--color-range", "0:1",
|
|
],
|
|
)
|
|
|
|
def test_hevc_color_bitstream_filter_carries_hdr_signal(self) -> None:
|
|
self.assertEqual(
|
|
ColorMetadata(9, 9, 16, 0).hevc_bitstream_filter(),
|
|
"hevc_metadata=colour_primaries=9:transfer_characteristics=16:matrix_coefficients=9:video_full_range_flag=0",
|
|
)
|
|
|
|
def test_x265_minimum_profile_tracks_depth_and_chroma(self) -> None:
|
|
self.assertEqual(
|
|
minimum_profile("libx265", VideoConstraintSpec(1920, 1080, 12, "420", 60)),
|
|
"main12",
|
|
)
|
|
self.assertEqual(
|
|
minimum_profile("libx265", VideoConstraintSpec(1920, 1080, 10, "422", 60)),
|
|
"main422-10",
|
|
)
|
|
self.assertEqual(
|
|
minimum_profile("libx265", VideoConstraintSpec(1920, 1080, 8, "444", 60)),
|
|
"main444-8",
|
|
)
|
|
|
|
def test_manual_x265_profile_is_upgraded_per_clip(self) -> None:
|
|
clip = VideoConstraintSpec(1920, 1080, 10, "422", 60)
|
|
self.assertEqual(resolve_profile("libx265", "main", clip), "main422-10")
|
|
self.assertEqual(resolve_profile("libx265", "main12", clip), "main422-12")
|
|
|
|
def test_manual_x264_profile_is_upgraded_per_clip(self) -> None:
|
|
clip = VideoConstraintSpec(1920, 1080, 10, "444", 60)
|
|
self.assertEqual(resolve_profile("libx264", "high", clip), "high444")
|
|
|
|
def test_profile_options_include_profiles_supported_by_some_clips(self) -> None:
|
|
clips = [
|
|
VideoConstraintSpec(1920, 1080, 8, "420", 30),
|
|
VideoConstraintSpec(1920, 1080, 10, "422", 30),
|
|
]
|
|
profiles = available_profiles("libx265", clips)
|
|
self.assertIn("main", profiles)
|
|
self.assertIn("main422-10", profiles)
|
|
self.assertNotIn("main444-12", profiles)
|
|
|
|
x264_profiles = available_profiles("libx264", [clips[0]])
|
|
self.assertEqual(x264_profiles, ["baseline", "main", "high"])
|
|
|
|
def test_minimum_hevc_level_and_manual_floor_are_per_clip(self) -> None:
|
|
hd = VideoConstraintSpec(1920, 1080, 10, "420", 60)
|
|
uhd = VideoConstraintSpec(3840, 2160, 10, "420", 60)
|
|
self.assertEqual(minimum_level("libx265", hd), "4.1")
|
|
self.assertEqual(minimum_level("libx265", uhd), "5.1")
|
|
self.assertEqual(resolve_level("libx265", "4.0", uhd), "5.1")
|
|
self.assertIn("4.1", available_levels("libx265", [hd, uhd]))
|
|
|
|
def test_none_omits_profile_and_level(self) -> None:
|
|
clip = VideoConstraintSpec(1920, 1080, 8, "420", 30)
|
|
self.assertIsNone(resolve_profile("libx265", None, clip))
|
|
self.assertIsNone(resolve_level("libx265", None, clip))
|
|
|
|
|
|
class HdrToneMapTests(unittest.TestCase):
|
|
def test_mpc_hable_mapping_matches_hdrtools_parameters(self) -> None:
|
|
def hable(value: float) -> float:
|
|
return (
|
|
(value * (0.15 * value + 0.10 * 0.50) + 0.20 * 0.02)
|
|
/ (value * (0.15 * value + 0.50) + 0.20 * 0.30)
|
|
- 0.02 / 0.30
|
|
)
|
|
|
|
display_nits = 125.0
|
|
exposure = 10_000.0 / display_nits
|
|
hdrtools_white_scale = 1.0 / hable(4.8)
|
|
for linear_pq in (0.0, 0.001, 0.01, 0.1, 0.5, 1.0):
|
|
mpc_value = hable(linear_pq * exposure) / hable(4.8)
|
|
hdrtools_value = hable(linear_pq * exposure) * hdrtools_white_scale
|
|
self.assertAlmostEqual(mpc_value, hdrtools_value, places=12)
|
|
|
|
def test_luminance_gamut_compression_preserves_luma_and_chroma_direction(self) -> None:
|
|
weights = (0.2126, 0.7152, 0.0722)
|
|
source = (1.3, 0.2, 0.1)
|
|
luma = sum(value * weight for value, weight in zip(source, weights))
|
|
minimum = min(source)
|
|
maximum = max(source)
|
|
saturation = min(
|
|
1.0,
|
|
(1.0 - luma) / (maximum - luma) if maximum > 1.0 else 1.0,
|
|
luma / (luma - minimum) if minimum < 0.0 else 1.0,
|
|
)
|
|
mapped = tuple(luma + (value - luma) * saturation for value in source)
|
|
|
|
self.assertTrue(all(0.0 <= value <= 1.0 for value in mapped))
|
|
self.assertAlmostEqual(sum(value * weight for value, weight in zip(mapped, weights)), luma)
|
|
for original, result in zip(source, mapped):
|
|
self.assertAlmostEqual((result - luma) / (original - luma), saturation)
|
|
|
|
|
|
class WorkspaceSummaryTests(unittest.TestCase):
|
|
def test_stale_workspace_manifests_require_missing_video(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
(root / "kept.mp4").write_bytes(b"")
|
|
(root / "kept.mp4.manifest.json").write_text("{}", encoding="utf-8")
|
|
missing = root / "missing.mp4.manifest.json"
|
|
missing.write_text("{}", encoding="utf-8")
|
|
|
|
self.assertEqual(stale_workspace_manifests(root), [missing])
|
|
|
|
def test_source_cleanup_retries_transient_windows_lock(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
source_root = Path(temp_dir) / ".source"
|
|
source_root.mkdir()
|
|
with (
|
|
patch(
|
|
"tools.avisynth_workspace.shutil.rmtree",
|
|
side_effect=[PermissionError("locked"), None],
|
|
) as remove,
|
|
patch("tools.avisynth_workspace.time.sleep") as sleep,
|
|
):
|
|
_clear_source_root(source_root)
|
|
|
|
self.assertEqual(remove.call_count, 2)
|
|
sleep.assert_called_once_with(0.25)
|
|
|
|
def test_existing_scripts_are_not_repeated(self) -> None:
|
|
root = Path("workspace")
|
|
items = [
|
|
VideoInput(Path("a.mp4"), Path("a.mp4"), Path("a.mp4")),
|
|
VideoInput(Path("b.mp4"), Path("b.mp4"), Path("b.mp4")),
|
|
]
|
|
existing = [root / "a.mp4.avs", root / "b.mp4.avs"]
|
|
|
|
output = self._summary(root, items, existing)
|
|
|
|
self.assertIn("existing editable scripts:", output)
|
|
self.assertNotIn("new editable scripts:", output)
|
|
self.assertEqual(output.count("a.mp4.avs"), 1)
|
|
self.assertEqual(output.count("b.mp4.avs"), 1)
|
|
|
|
def test_mixed_scripts_are_split_into_existing_and_new(self) -> None:
|
|
root = Path("workspace")
|
|
items = [
|
|
VideoInput(Path("a.mp4"), Path("a.mp4"), Path("a.mp4")),
|
|
VideoInput(Path("b.mp4"), Path("b.mp4"), Path("b.mp4")),
|
|
]
|
|
|
|
output = self._summary(root, items, [root / "a.mp4.avs"])
|
|
|
|
self.assertIn("existing editable scripts:", output)
|
|
self.assertIn("new editable scripts:", output)
|
|
self.assertEqual(output.count("a.mp4.avs"), 1)
|
|
self.assertEqual(output.count("b.mp4.avs"), 1)
|
|
|
|
def test_new_only_uses_plain_editable_heading(self) -> None:
|
|
root = Path("workspace")
|
|
item = VideoInput(Path("a.mp4"), Path("a.mp4"), Path("a.mp4"))
|
|
|
|
output = self._summary(root, [item], [])
|
|
|
|
self.assertIn(" editable scripts:", output)
|
|
self.assertNotIn("new editable scripts:", output)
|
|
|
|
def test_validation_prompt_defaults_to_fast_and_accepts_real(self) -> None:
|
|
with (
|
|
patch("video_encode.sys.stdin.isatty", return_value=True),
|
|
patch("video_encode.prompt_input", return_value=""),
|
|
):
|
|
self.assertEqual(wait_for_script_edits(), "fast")
|
|
with (
|
|
patch("video_encode.sys.stdin.isatty", return_value=True),
|
|
patch("video_encode.prompt_input", return_value="r"),
|
|
):
|
|
self.assertEqual(wait_for_script_edits(), "real")
|
|
|
|
@staticmethod
|
|
def _summary(root, items, existing) -> str:
|
|
output = StringIO()
|
|
with redirect_stdout(output):
|
|
print_workspace_summary(root, items, [], existing)
|
|
return output.getvalue()
|
|
|
|
|
|
class VideoTimelineTests(unittest.TestCase):
|
|
def test_timecode_v2_includes_final_frame_end(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "frames.txt"
|
|
write_timecode_v2(path, [0.04, 0.06, 0.05])
|
|
|
|
self.assertEqual(
|
|
path.read_text(encoding="utf-8").splitlines(),
|
|
[
|
|
"# timecode format v2",
|
|
"0.000000000",
|
|
"40.000000000",
|
|
"100.000000000",
|
|
"150.000000000",
|
|
],
|
|
)
|
|
|
|
def test_x264_vfr_command_uses_timecodes_and_output_format(self) -> None:
|
|
options = VideoCodecOptions(
|
|
codec="libx264",
|
|
crf=18,
|
|
preset="slow",
|
|
profile="high10",
|
|
level="4.1",
|
|
threads=3,
|
|
)
|
|
command = x264_command(
|
|
request=VideoEncodeRequest(
|
|
y4m_command=[],
|
|
ffmpeg=Path("ffmpeg"),
|
|
script=Path("video.avs"),
|
|
audio_source=Path("audio.mp4"),
|
|
output=Path("video.mp4"),
|
|
options=options,
|
|
audio_options=AudioEncodeOptions(mode="none"),
|
|
audio_start=0,
|
|
audio_duration=None,
|
|
audio_segments=None,
|
|
audio_tempo=1,
|
|
audio_sample_rate=None,
|
|
source_has_audio=False,
|
|
allow_audio_copy=False,
|
|
pixel_format="yuv422p10le",
|
|
colorspace="bt709",
|
|
color_metadata=None,
|
|
static_hdr=None,
|
|
dynamic_hdr10plus=None,
|
|
frame_count=None,
|
|
progress_label="video",
|
|
),
|
|
x264=Path("x264"),
|
|
timecodes=Path("frames.txt"),
|
|
output=Path("video.h264"),
|
|
)
|
|
|
|
self.assertIn("--tcfile-in", command)
|
|
self.assertEqual(command[command.index("--output-depth") + 1], "10")
|
|
self.assertEqual(command[command.index("--output-csp") + 1], "i422")
|
|
self.assertEqual(command[command.index("--colormatrix") + 1], "bt709")
|
|
self.assertEqual(command[command.index("--threads") + 1], "3")
|
|
|
|
def test_encoder_threads_use_x265_worker_pools(self) -> None:
|
|
command: list[str] = []
|
|
|
|
add_video_threads(
|
|
command,
|
|
VideoCodecOptions(codec="libx265", crf=24, preset="slow", threads=3),
|
|
)
|
|
|
|
self.assertEqual(command, ["-x265-params", "pools=3"])
|
|
|
|
def test_color_metadata_carries_hdr_properties(self) -> None:
|
|
self.assertEqual(
|
|
ColorMetadata(9, 9, 16, 0).ffmpeg_args(),
|
|
[
|
|
"-colorspace", "bt2020nc",
|
|
"-color_primaries", "bt2020",
|
|
"-color_trc", "smpte2084",
|
|
"-color_range", "tv",
|
|
],
|
|
)
|
|
|
|
def test_hdr10_and_mastering_display_detection(self) -> None:
|
|
self.assertEqual(hdr_kind("smpte2084"), "HDR10")
|
|
self.assertEqual(hdr_kind("smpte2084", dynamic=True), "HDR10+")
|
|
self.assertTrue(
|
|
_hdr_side_data(
|
|
[{"side_data_type": "HDR Dynamic Metadata SMPTE2094-40 (HDR10+)"}]
|
|
)[-1]
|
|
)
|
|
self.assertEqual(
|
|
_format_mastering_display(
|
|
{
|
|
"green_x": "13250/50000",
|
|
"green_y": "34500/50000",
|
|
"blue_x": "7500/50000",
|
|
"blue_y": "3000/50000",
|
|
"red_x": "34000/50000",
|
|
"red_y": "16000/50000",
|
|
"white_point_x": "15635/50000",
|
|
"white_point_y": "16450/50000",
|
|
"max_luminance": "40000000/10000",
|
|
"min_luminance": "50/10000",
|
|
}
|
|
),
|
|
"G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(40000000,50)",
|
|
)
|
|
|
|
def test_hdr10plus_selection_reindexes_scenes(self) -> None:
|
|
entries = [
|
|
{"LuminanceParameters": {"AverageRGB": value}, "NumberOfWindows": 1,
|
|
"TargetedSystemDisplayMaximumLuminance": 1000,
|
|
"SceneFrameIndex": index, "SceneId": 0, "SequenceFrameIndex": index}
|
|
for index, value in enumerate((10, 10, 20, 20, 30))
|
|
]
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
source = Path(temp_dir) / "source.json"
|
|
output = Path(temp_dir) / "selected.json"
|
|
source.write_text(json.dumps({"SceneInfo": entries}), encoding="utf-8")
|
|
select_hdr10plus_frames(
|
|
source_json=source,
|
|
source_frames=[1, 2, 4],
|
|
output_json=output,
|
|
)
|
|
selected = json.loads(output.read_text(encoding="utf-8"))
|
|
|
|
self.assertEqual(
|
|
[entry["SequenceFrameIndex"] for entry in selected["SceneInfo"]], [0, 1, 2]
|
|
)
|
|
self.assertEqual(
|
|
[entry["SceneFrameIndex"] for entry in selected["SceneInfo"]], [0, 0, 0]
|
|
)
|
|
self.assertEqual(selected["SceneInfoSummary"], {
|
|
"SceneFirstFrameIndex": [0, 1, 2],
|
|
"SceneFrameNumbers": [1, 1, 1],
|
|
})
|
|
|
|
def test_dynamic_hdr10plus_is_passed_to_x265(self) -> None:
|
|
command: list[str] = []
|
|
add_dynamic_hdr10plus_metadata(
|
|
command,
|
|
VideoCodecOptions(codec="libx265", crf=21, preset="slow"),
|
|
Path("metadata.json"),
|
|
)
|
|
self.assertEqual(
|
|
command,
|
|
[
|
|
"-x265-params",
|
|
f"dhdr10-info={Path('metadata.json').resolve()}:dhdr10-opt=1",
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
x265_parameter_path(Path(r"C:\work\metadata.json")),
|
|
r"C\:/work/metadata.json",
|
|
)
|
|
|
|
def test_encoder_statistics_keep_frame_and_reference_lines(self) -> None:
|
|
statistics = encoder_statistics_lines(
|
|
"x264 [info]: frame I:2 Avg QP:18.00\n"
|
|
"x264 [info]: frame P:5 Avg QP:20.00\n"
|
|
"x264 [info]: frame B:9 Avg QP:21.00\n"
|
|
"x264 [info]: consecutive B-frames: 10.0% 20.0%\n"
|
|
"x264 [info]: ref P L0: 75.0% 25.0%\n"
|
|
"x264 [info]: kb/s:1234.56\n"
|
|
)
|
|
|
|
self.assertEqual(
|
|
statistics,
|
|
[
|
|
"frame I:2 Avg QP:18.00",
|
|
"frame P:5 Avg QP:20.00",
|
|
"frame B:9 Avg QP:21.00",
|
|
"consecutive B-frames: 10.0% 20.0%",
|
|
"ref P L0: 75.0% 25.0%",
|
|
],
|
|
)
|
|
|
|
def test_timestamped_y4m_uses_timeline_average_rate(self) -> None:
|
|
probe = make_probe(timestamps=[0.0, 0.04, 0.10], duration=0.16)
|
|
timeline = build_output_timeline(
|
|
[
|
|
FrameIdentity(output_frame=0, source_id=1, source_frame=0),
|
|
FrameIdentity(output_frame=1, source_id=1, source_frame=1),
|
|
FrameIdentity(output_frame=2, source_id=1, source_frame=2),
|
|
],
|
|
probe=probe,
|
|
)
|
|
|
|
command = y4m_command_for_timeline(
|
|
Path("runner"), timeline, force_timeline_rate=True
|
|
)
|
|
|
|
self.assertEqual(command[:2], ["runner", "--y4m-skip-drop"])
|
|
self.assertEqual(command[2:], ["75", "4"])
|
|
|
|
def test_validation_progress_embeds_percent_at_fifty_percent_cutoff(self) -> None:
|
|
self.assertEqual(
|
|
_progress_bar(1.0, embedded_percent=True),
|
|
"[######## 100.0% ########]",
|
|
)
|
|
self.assertEqual(
|
|
_progress_bar(0.801, embedded_percent=True),
|
|
"[###### 80.1% ######-----]",
|
|
)
|
|
self.assertEqual(
|
|
_progress_bar(0.499, embedded_percent=True),
|
|
"[############-- 49.9% ---]",
|
|
)
|
|
|
|
def test_progress_eta_samples_ignore_duplicate_frame_updates(self) -> None:
|
|
progress = ProgressView(10, "Encoding", stream=StringIO())
|
|
|
|
progress.update(1)
|
|
progress.update(2)
|
|
progress.update(2)
|
|
progress.update(2)
|
|
|
|
self.assertEqual([frame for frame, _ in progress.samples], [1, 2])
|
|
self.assertEqual(
|
|
_progress_bar(0.5, embedded_percent=True),
|
|
"[## 50.0% ###------------]",
|
|
)
|
|
|
|
def test_progress_can_weight_percent_and_eta_by_work(self) -> None:
|
|
progress = ProgressView(4, "Applying changes", total_work=10, stream=StringIO())
|
|
|
|
line = progress.update_line(1, completed_work=5)
|
|
|
|
self.assertIn("1/4", line)
|
|
self.assertIn("50.0%", line)
|
|
|
|
def test_windows_console_progress_does_not_require_stdout_isatty(self) -> None:
|
|
class ConsoleBuffer(StringIO):
|
|
def isatty(self) -> bool:
|
|
return False
|
|
|
|
output = ConsoleBuffer()
|
|
with (
|
|
patch("tools.console.os.name", "nt"),
|
|
patch("tools.console.sys.stdout", output),
|
|
patch("tools.console._windows_console_output", return_value=True),
|
|
):
|
|
self.assertTrue(stream_is_interactive(output))
|
|
progress = ProgressView(3, "Applying changes", stream=output)
|
|
progress.update(1)
|
|
progress.update(2)
|
|
progress.finish(keep=True)
|
|
|
|
self.assertEqual(output.getvalue().count("\n"), 1)
|
|
|
|
def test_single_line_ansi_progress_never_moves_up_zero_lines(self) -> None:
|
|
class TtyBuffer(StringIO):
|
|
def isatty(self) -> bool:
|
|
return True
|
|
|
|
output = TtyBuffer()
|
|
with patch("tools.console.supports_ansi", return_value=True):
|
|
progress = ProgressView(3, "Reading metadata", stream=output)
|
|
progress.update(1)
|
|
progress.update(2)
|
|
progress.finish(keep=True)
|
|
|
|
self.assertNotIn("\x1b[0F", output.getvalue())
|
|
|
|
def test_progress_redraws_a_wrapped_multi_line_block(self) -> None:
|
|
class TtyBuffer(StringIO):
|
|
def isatty(self) -> bool:
|
|
return True
|
|
|
|
output = TtyBuffer()
|
|
with (
|
|
patch("tools.console.supports_ansi", return_value=True),
|
|
patch.dict("os.environ", {"COLUMNS": "30"}),
|
|
):
|
|
progress = ProgressView(3, "Progress", stream=output)
|
|
progress.write_lines(
|
|
[
|
|
"Applying changes",
|
|
" Results: succeeded 1 failed 0 metadata 1 no-op 0",
|
|
" File ops: rename 0 move 0 rename+move 0",
|
|
]
|
|
)
|
|
progress.write_lines(["Applying changes", " Results: succeeded 2 failed 0 metadata 2 no-op 0"])
|
|
progress.finish(keep=True)
|
|
|
|
rendered = output.getvalue()
|
|
self.assertIn("\x1b[2K", rendered)
|
|
self.assertIn("\x1b[", rendered)
|
|
|
|
def test_encoding_progress_matches_persistent_validation_style(self) -> None:
|
|
class TtyBuffer(StringIO):
|
|
def isatty(self) -> bool:
|
|
return True
|
|
|
|
class Process:
|
|
stderr = iter(["frame=2\n", "frame=10\n", "progress=end\n"])
|
|
returncode = None
|
|
|
|
def wait(self) -> None:
|
|
self.returncode = 0
|
|
|
|
output = TtyBuffer()
|
|
with patch("tools.video_encode_output.sys.stdout", output):
|
|
read_ffmpeg_progress(
|
|
Process(),
|
|
total_frames=10,
|
|
label="Encoding script 1/1: clip.mp4",
|
|
)
|
|
|
|
rendered = output.getvalue()
|
|
self.assertIn("Encoding script 1/1: clip.mp4", rendered)
|
|
self.assertIn("100.0%", rendered)
|
|
self.assertIn("fps", rendered)
|
|
self.assertTrue(rendered.endswith("\n"))
|
|
|
|
def test_pipeline_progress_accepts_runner_render_protocol(self) -> None:
|
|
class TtyBuffer(StringIO):
|
|
def isatty(self) -> bool:
|
|
return True
|
|
|
|
output = TtyBuffer()
|
|
with patch("tools.console.sys.stdout", output):
|
|
progress = PipelineProgressView(
|
|
rendering_label="Rendering script 1/1: clip.mp4",
|
|
encoding_label="Encoding script 1/1: clip.mp4",
|
|
encoding_total=10,
|
|
stream=output,
|
|
)
|
|
self.assertTrue(
|
|
_update_renderer_progress(
|
|
progress,
|
|
"mbt_render,2,10,20.00,4.000,16.000\n",
|
|
)
|
|
)
|
|
progress.update_encoding(1)
|
|
progress.finish()
|
|
|
|
rendered = output.getvalue()
|
|
self.assertIn("Rendering script 1/1: clip.mp4", rendered)
|
|
self.assertIn("Encoding script 1/1: clip.mp4", rendered)
|
|
self.assertIn("2/10", rendered)
|
|
|
|
def test_drop_frame_extends_previous_frame_duration(self) -> None:
|
|
probe = make_probe(timestamps=[0.0, 1.0, 2.0, 3.0], duration=4.0)
|
|
timeline = build_output_timeline(
|
|
[
|
|
FrameIdentity(output_frame=0, source_id=1, source_frame=0),
|
|
FrameIdentity(output_frame=1, source_id=1, source_frame=1, drop_frame=True),
|
|
FrameIdentity(output_frame=2, source_id=1, source_frame=2),
|
|
FrameIdentity(output_frame=3, source_id=1, source_frame=3, drop_frame=True),
|
|
],
|
|
probe=probe,
|
|
)
|
|
|
|
self.assertEqual(timeline.dropped_frame_count, 2)
|
|
self.assertEqual([frame.source_frame for frame in timeline.frames], [0, 2])
|
|
self.assertEqual([frame.duration for frame in timeline.frames], [2.0, 2.0])
|
|
self.assertEqual(timeline.duration_seconds, 4.0)
|
|
self.assertEqual(timeline.timing_kind, "cfr")
|
|
self.assertFalse(timeline.beginning_trimmed)
|
|
self.assertFalse(timeline.ending_trimmed)
|
|
|
|
def test_vfr_source_durations_are_preserved(self) -> None:
|
|
probe = make_probe(timestamps=[0.0, 0.04, 0.10], duration=0.16)
|
|
timeline = build_output_timeline(
|
|
[
|
|
FrameIdentity(output_frame=0, source_id=1, source_frame=0),
|
|
FrameIdentity(output_frame=1, source_id=1, source_frame=1),
|
|
FrameIdentity(output_frame=2, source_id=1, source_frame=2),
|
|
],
|
|
probe=probe,
|
|
)
|
|
|
|
self.assertAlmostEqual(timeline.duration_seconds, 0.16)
|
|
self.assertEqual([round(frame.duration, 3) for frame in timeline.frames], [0.04, 0.06, 0.06])
|
|
self.assertEqual(timeline.timing_kind, "vfr")
|
|
|
|
def test_timeline_uses_one_validated_absolute_timestretch(self) -> None:
|
|
probe = make_probe(timestamps=[0.0, 1.0], duration=2.0)
|
|
timeline = build_output_timeline(
|
|
[
|
|
FrameIdentity(0, 1, 0, absolute_timestretch=0.125),
|
|
FrameIdentity(1, 1, 1, absolute_timestretch=0.125),
|
|
],
|
|
probe=probe,
|
|
)
|
|
|
|
self.assertEqual(timeline.absolute_timestretch, 0.125)
|
|
|
|
def test_metadata_timestretch_is_not_an_avisynth_speed_change(self) -> None:
|
|
probe = make_probe(timestamps=[0.0, 1.0], duration=2.0)
|
|
probe = replace(probe, avg_frame_rate="60/1", r_frame_rate="60/1")
|
|
timeline = OutputTimeline(
|
|
frames=[
|
|
OutputFrameTiming(0, 1, 0, 0.0, 1.0),
|
|
OutputFrameTiming(1, 1, 1, 1.0, 1.0),
|
|
],
|
|
audio_segments=[(0.0, 2.0)],
|
|
consumed_source_frames=[0, 1],
|
|
duration_seconds=2.0,
|
|
timing_kind="cfr",
|
|
beginning_trimmed=False,
|
|
ending_trimmed=False,
|
|
dropped_frame_count=0,
|
|
source_frame_count=2,
|
|
matrix=None,
|
|
absolute_timestretch=0.125,
|
|
)
|
|
clip_info = AvisynthClipInfo(
|
|
width=1920,
|
|
height=1080,
|
|
chroma="420",
|
|
bit_depth=8,
|
|
frames=123,
|
|
fps_num=60,
|
|
fps_den=1,
|
|
has_audio=True,
|
|
audio_rate=48000,
|
|
audio_channels=2,
|
|
audio_samples=0,
|
|
)
|
|
|
|
self.assertFalse(
|
|
has_avisynth_speed_changes(
|
|
{probe.path: timeline},
|
|
{probe.path: clip_info},
|
|
{probe.path: probe},
|
|
)
|
|
)
|
|
|
|
def test_validation_csv_carries_output_color_properties(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
csv_path = Path(temp_dir) / "frames.csv"
|
|
csv_path.write_text(
|
|
"output_frame,source_id,source_frame,drop_frame,matrix,primaries,transfer,color_range,absolute_timestretch\n"
|
|
"0,1,0,0,9,9,16,0,1\n",
|
|
encoding="utf-8",
|
|
)
|
|
frame = read_frame_identity_csv(csv_path)[0]
|
|
|
|
self.assertEqual((frame.matrix, frame.primaries, frame.transfer, frame.color_range), (9, 9, 16, 0))
|
|
|
|
def test_timeline_rejects_mixed_absolute_timestretches(self) -> None:
|
|
probe = make_probe(timestamps=[0.0, 1.0], duration=2.0)
|
|
|
|
with self.assertRaisesRegex(RuntimeError, "mixed absolute timestretch"):
|
|
build_output_timeline(
|
|
[
|
|
FrameIdentity(0, 1, 0, absolute_timestretch=1.0),
|
|
FrameIdentity(1, 1, 1, absolute_timestretch=0.5),
|
|
],
|
|
probe=probe,
|
|
)
|
|
|
|
def test_complex_source_span_is_summarized(self) -> None:
|
|
probe = make_probe(
|
|
timestamps=[float(index) for index in range(10)],
|
|
duration=10.0,
|
|
)
|
|
timeline = build_output_timeline(
|
|
[
|
|
FrameIdentity(index, 1, source_frame)
|
|
for index, source_frame in enumerate((0, 2, 4, 6, 8))
|
|
],
|
|
probe=probe,
|
|
)
|
|
|
|
self.assertEqual(
|
|
_format_source_span(timeline),
|
|
"0 -> 8, 5 source frames in 5 spans of 10",
|
|
)
|
|
|
|
def test_validation_summary_compares_changed_values(self) -> None:
|
|
probe = make_probe(
|
|
timestamps=[float(index) for index in range(4)],
|
|
duration=4.0,
|
|
metadata_end=datetime(2026, 1, 1, 12, 0, 4, tzinfo=timezone.utc),
|
|
)
|
|
timeline = build_output_timeline(
|
|
[FrameIdentity(0, 1, 1), FrameIdentity(1, 1, 2)],
|
|
probe=probe,
|
|
)
|
|
clip_info = AvisynthClipInfo(
|
|
width=1280,
|
|
height=720,
|
|
chroma="420",
|
|
bit_depth=8,
|
|
frames=2,
|
|
fps_num=1,
|
|
fps_den=1,
|
|
has_audio=True,
|
|
audio_rate=48000,
|
|
audio_channels=1,
|
|
audio_samples=96000,
|
|
)
|
|
|
|
lines = format_validation_summary_lines(
|
|
output_frame_count=2,
|
|
dropped_frame_count=0,
|
|
timeline=timeline,
|
|
clip_info=clip_info,
|
|
probe=probe,
|
|
)
|
|
|
|
self.assertIn("duration: 2.000s, 2 frames (4.000s, 4 frames)", lines)
|
|
self.assertIn("source span: 1 -> 2 of 4", lines)
|
|
self.assertIn(
|
|
"video: CFR 1.000 fps, 1280x720, 4:2:0, 8-bit, unknown "
|
|
"(h264, 1.000 fps, 1920x1080, Rec709, 10 Mbit/s)",
|
|
lines,
|
|
)
|
|
self.assertFalse(any(line.startswith("framerate:") for line in lines))
|
|
self.assertTrue(any("audio: 1 channel, 48000 Hz (aac, 2 channels, 192 kbit/s)" in line for line in lines))
|
|
self.assertIn("timestamp: 2026-01-01 12:00:01 (12:00:00)", lines)
|
|
|
|
def test_validation_summary_marks_source_vfr_when_output_is_cfr(self) -> None:
|
|
probe = make_probe(
|
|
timestamps=[0.0, 0.02, 0.04, 0.08, 0.10],
|
|
duration=0.12,
|
|
)
|
|
timeline = build_output_timeline(
|
|
[FrameIdentity(0, 1, 0), FrameIdentity(1, 1, 1)],
|
|
probe=probe,
|
|
)
|
|
|
|
lines = format_validation_summary_lines(
|
|
output_frame_count=2,
|
|
dropped_frame_count=0,
|
|
timeline=timeline,
|
|
clip_info=None,
|
|
probe=probe,
|
|
)
|
|
|
|
self.assertEqual(timeline.timing_kind, "cfr")
|
|
self.assertIn("h264, VFR 1.000 fps", lines[2])
|
|
|
|
def test_probe_timestamp_uses_recorded_timezone_and_one_date(self) -> None:
|
|
probe = make_probe(
|
|
timestamps=[0.0, 1.0],
|
|
duration=7.0,
|
|
metadata_end=datetime(2026, 7, 4, 14, 13, 56, tzinfo=timezone.utc),
|
|
source_timezone=timezone(timedelta(hours=2)),
|
|
)
|
|
|
|
self.assertEqual(
|
|
_format_probe_timestamp(probe),
|
|
"2026-07-04 16:13:49 - 16:13:56 +02:00",
|
|
)
|
|
|
|
def test_android_timezone_is_accepted_as_source_metadata(self) -> None:
|
|
parsed = _video_timezone({"QuickTime:AndroidTimeZone": "+0200"})
|
|
|
|
self.assertIsNotNone(parsed)
|
|
self.assertEqual(parsed.utcoffset(None), timedelta(hours=2))
|
|
|
|
def test_encoding_timezone_prefers_one_recorded_offset(self) -> None:
|
|
probes = [
|
|
make_probe(timestamps=[0.0], duration=1.0),
|
|
make_probe(
|
|
timestamps=[0.0],
|
|
duration=1.0,
|
|
source_timezone=timezone(timedelta(hours=9)),
|
|
),
|
|
]
|
|
|
|
self.assertEqual(
|
|
_default_output_timezone(probes).utcoffset(None),
|
|
timedelta(hours=9),
|
|
)
|
|
|
|
def test_environment_encoding_settings_preserve_automation_aliases(self) -> None:
|
|
environment = {
|
|
"MBT_VIDEO_TIMESTAMP_NAMES": "yes",
|
|
"MBT_VIDEO_TIMEZONE": "+02:00",
|
|
"MBT_VIDEO_CONTAINER": "matroska",
|
|
"MBT_VIDEO_CODEC": "stream-copy",
|
|
"MBT_VIDEO_CRF": "18",
|
|
"MBT_VIDEO_PRESET": "slower",
|
|
"MBT_VIDEO_PROFILE": "main10",
|
|
"MBT_VIDEO_LEVEL": "4.1",
|
|
"MBT_VIDEO_THREADS": "3",
|
|
"MBT_VIDEO_AUDIO": "streamcopy",
|
|
"MBT_VIDEO_AUDIO_PITCH": "speed",
|
|
}
|
|
with patch.dict(os.environ, environment, clear=True):
|
|
settings = _environment_encoding_settings(timezone.utc)
|
|
|
|
self.assertTrue(settings.naming.use_timestamps)
|
|
self.assertEqual(settings.naming.timezone_value.utcoffset(None), timedelta(hours=2))
|
|
self.assertEqual(settings.codec_options, VideoCodecOptions(
|
|
codec="copy", crf=18, preset="slower", extension=".mkv",
|
|
profile="main10", level="4.1", threads=3,
|
|
))
|
|
self.assertEqual(settings.audio_options, AudioEncodeOptions(
|
|
mode="copy", bitrate="", preserve_pitch=False,
|
|
))
|
|
|
|
def test_timestamp_naming_off_skips_timezone_step(self) -> None:
|
|
self.assertNotIn(
|
|
"timezone",
|
|
_encoding_steps(make_answers(timestamp_names=False), has_speed_changes=False),
|
|
)
|
|
|
|
def test_threads_setting_follows_level_and_requires_positive_integer(self) -> None:
|
|
steps = _encoding_steps(make_answers(), has_speed_changes=False)
|
|
|
|
self.assertEqual(steps[steps.index("level") + 1], "threads")
|
|
self.assertEqual(_parse_answer("threads", "auto", make_answers(), []), "auto")
|
|
self.assertEqual(_parse_answer("threads", "2", make_answers(), []), 2)
|
|
with self.assertRaisesRegex(ValueError, "positive integer"):
|
|
_parse_answer("threads", "0", make_answers(), [])
|
|
|
|
def test_default_crf_depends_on_codec(self) -> None:
|
|
self.assertEqual(_default_answer("crf", make_answers(codec="libx264"), []), 16)
|
|
self.assertEqual(_default_answer("crf", make_answers(codec="libx265"), []), 21)
|
|
|
|
def test_changed_codec_resets_dependent_answers(self) -> None:
|
|
answers = make_answers(codec="libx264", crf=16, preset="slow", threads=2)
|
|
answers.set("codec", "libx265")
|
|
|
|
self.assertEqual(_default_answer("crf", answers, []), 21)
|
|
self.assertEqual(_default_answer("threads", answers, []), "auto")
|
|
|
|
def test_container_question_lists_available_options(self) -> None:
|
|
question = _question_for_key("container", make_answers(), [])
|
|
|
|
self.assertIn("(mp4/mkv)", question)
|
|
|
|
def test_preset_question_lists_available_options(self) -> None:
|
|
question = _question_for_key("preset", make_answers(), [])
|
|
|
|
self.assertIn("ultrafast/superfast/veryfast", question)
|
|
self.assertIn("slow/slower/veryslow/placebo", question)
|
|
|
|
def test_vfr_summary_places_milliseconds_in_heading(self) -> None:
|
|
probe = make_probe(
|
|
timestamps=[0.0, 0.016667, 0.033334, 0.066667],
|
|
duration=0.1,
|
|
)
|
|
|
|
summary = _format_vfr_timing(probe)
|
|
|
|
self.assertIn("rounded deltas (ms):", summary)
|
|
self.assertIn("common deltas (ms):", summary)
|
|
self.assertIn("16.667 2x", summary)
|
|
self.assertNotIn(" ms", summary)
|
|
|
|
def test_drop_frames_do_not_require_audio_cuts(self) -> None:
|
|
timeline = OutputTimeline(
|
|
frames=[
|
|
OutputFrameTiming(0, 1, 0, 0.0, 2.0),
|
|
OutputFrameTiming(1, 1, 2, 2.0, 1.0),
|
|
],
|
|
audio_segments=[(0.0, 3.0)],
|
|
consumed_source_frames=[0, 1, 2],
|
|
duration_seconds=3.0,
|
|
timing_kind="vfr",
|
|
beginning_trimmed=False,
|
|
ending_trimmed=False,
|
|
dropped_frame_count=1,
|
|
source_frame_count=3,
|
|
matrix=None,
|
|
)
|
|
|
|
self.assertEqual(normal_deleted_frame_count(timeline), 0)
|
|
|
|
def test_normal_frame_deletion_requires_audio_cut_segments(self) -> None:
|
|
timeline = OutputTimeline(
|
|
frames=[
|
|
OutputFrameTiming(0, 1, 0, 0.0, 1.0),
|
|
OutputFrameTiming(1, 1, 1, 1.0, 1.0),
|
|
OutputFrameTiming(2, 1, 3, 3.0, 1.0),
|
|
OutputFrameTiming(3, 1, 5, 5.0, 1.0),
|
|
OutputFrameTiming(4, 1, 6, 6.0, 1.0),
|
|
],
|
|
audio_segments=[(0.0, 2.0), (3.0, 1.0), (5.0, 2.0)],
|
|
consumed_source_frames=[0, 1, 3, 5, 6],
|
|
duration_seconds=5.0,
|
|
timing_kind="vfr",
|
|
beginning_trimmed=False,
|
|
ending_trimmed=False,
|
|
dropped_frame_count=0,
|
|
source_frame_count=7,
|
|
matrix=None,
|
|
)
|
|
|
|
self.assertEqual(normal_deleted_frame_count(timeline), 2)
|
|
self.assertEqual(timeline.audio_segments, [(0.0, 2.0), (3.0, 1.0), (5.0, 2.0)])
|
|
|
|
def test_mixed_delete_and_drop_frame_audio_segments(self) -> None:
|
|
probe = make_probe(timestamps=[0.0, 1.0, 2.0, 3.0, 4.0], duration=5.0)
|
|
timeline = build_output_timeline(
|
|
[
|
|
FrameIdentity(output_frame=0, source_id=1, source_frame=0),
|
|
FrameIdentity(output_frame=1, source_id=1, source_frame=2),
|
|
FrameIdentity(output_frame=2, source_id=1, source_frame=3, drop_frame=True),
|
|
FrameIdentity(output_frame=3, source_id=1, source_frame=4),
|
|
],
|
|
probe=probe,
|
|
)
|
|
|
|
self.assertEqual([frame.source_frame for frame in timeline.frames], [0, 2, 4])
|
|
self.assertEqual([frame.duration for frame in timeline.frames], [1.0, 2.0, 1.0])
|
|
self.assertEqual(timeline.audio_segments, [(0.0, 1.0), (2.0, 3.0)])
|
|
self.assertEqual(timeline.consumed_source_frames, [0, 2, 3, 4])
|
|
self.assertEqual(normal_deleted_frame_count(timeline), 1)
|
|
|
|
|
|
class AudioFilterTests(unittest.TestCase):
|
|
def test_preserve_pitch_uses_atempo_chain(self) -> None:
|
|
self.assertEqual(atempo_filters(0.25), ["atempo=0.5", "atempo=0.50000000"])
|
|
self.assertEqual(
|
|
speed_change_filters(
|
|
2.0,
|
|
AudioEncodeOptions(mode="aac", preserve_pitch=True),
|
|
48000,
|
|
),
|
|
["atempo=2.00000000"],
|
|
)
|
|
|
|
def test_shift_pitch_changes_sample_rate_then_resamples(self) -> None:
|
|
self.assertEqual(
|
|
speed_change_filters(
|
|
0.5,
|
|
AudioEncodeOptions(mode="aac", preserve_pitch=False),
|
|
48000,
|
|
),
|
|
["asetrate=24000", "aresample=48000"],
|
|
)
|
|
|
|
|
|
class OutputNamingTests(unittest.TestCase):
|
|
def test_backup_move_uses_a_unique_workspace_path(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary)
|
|
source = root / "source" / "original.mp4"
|
|
output = root / "encoded.mp4"
|
|
source.parent.mkdir()
|
|
source.write_bytes(b"source")
|
|
output.write_bytes(b"output")
|
|
item = EncodedItem(
|
|
VideoInput(source, Path("original.mp4"), Path("original.mp4")),
|
|
output,
|
|
)
|
|
|
|
backup = move_encoded_output_to_source_dir(root, item, "backup")
|
|
|
|
self.assertEqual(backup, root / "originals" / "original.mp4")
|
|
self.assertEqual(backup.read_bytes(), b"source")
|
|
self.assertEqual((source.parent / "encoded.mp4").read_bytes(), b"output")
|
|
|
|
def test_output_begin_uses_source_end_minus_source_duration_plus_first_frame(self) -> None:
|
|
probe = make_probe(
|
|
timestamps=[0.0, 1.0, 2.0],
|
|
duration=10.0,
|
|
metadata_end=datetime(2026, 6, 2, 12, 0, 10, tzinfo=timezone.utc),
|
|
)
|
|
timeline = OutputTimeline(
|
|
frames=[
|
|
OutputFrameTiming(
|
|
output_index=0,
|
|
source_id=1,
|
|
source_frame=2,
|
|
start=2.0,
|
|
duration=1.0,
|
|
)
|
|
],
|
|
audio_segments=[(2.0, 1.0)],
|
|
consumed_source_frames=[2],
|
|
duration_seconds=1.0,
|
|
timing_kind="cfr",
|
|
beginning_trimmed=True,
|
|
ending_trimmed=True,
|
|
dropped_frame_count=0,
|
|
source_frame_count=3,
|
|
matrix=None,
|
|
)
|
|
|
|
self.assertEqual(
|
|
output_begin_timestamp(probe, timeline, timezone.utc),
|
|
datetime(2026, 6, 2, 12, 0, 2, tzinfo=timezone.utc),
|
|
)
|
|
|
|
def test_absolute_timestretch_changes_metadata_timestamps(self) -> None:
|
|
probe = make_probe(
|
|
timestamps=[0.0, 27.0],
|
|
duration=27.64,
|
|
metadata_end=datetime(2026, 7, 23, 16, 57, 11, tzinfo=timezone.utc),
|
|
)
|
|
timeline = OutputTimeline(
|
|
frames=[
|
|
OutputFrameTiming(0, 1, 0, 0.0, 27.0),
|
|
OutputFrameTiming(1, 1, 1, 27.0, 0.64),
|
|
],
|
|
audio_segments=[(0.0, 27.64)],
|
|
consumed_source_frames=[0, 1],
|
|
duration_seconds=27.64,
|
|
timing_kind="cfr",
|
|
beginning_trimmed=False,
|
|
ending_trimmed=False,
|
|
dropped_frame_count=0,
|
|
source_frame_count=2,
|
|
matrix=None,
|
|
absolute_timestretch=0.125,
|
|
)
|
|
|
|
self.assertEqual(
|
|
output_begin_timestamp(probe, timeline, timezone.utc),
|
|
datetime(2026, 7, 23, 16, 57, 7, 545000, tzinfo=timezone.utc),
|
|
)
|
|
self.assertEqual(
|
|
output_end_timestamp(probe, timeline),
|
|
probe.metadata_end,
|
|
)
|
|
|
|
trimmed_timeline = replace(
|
|
timeline,
|
|
frames=[OutputFrameTiming(0, 1, 1, 27.0, 0.64)],
|
|
audio_segments=[(27.0, 0.64)],
|
|
consumed_source_frames=[1],
|
|
duration_seconds=0.64,
|
|
beginning_trimmed=True,
|
|
)
|
|
self.assertEqual(
|
|
output_begin_timestamp(probe, trimmed_timeline, timezone.utc),
|
|
datetime(2026, 7, 23, 16, 57, 10, 920000, tzinfo=timezone.utc),
|
|
)
|
|
self.assertEqual(
|
|
output_end_timestamp(probe, trimmed_timeline),
|
|
probe.metadata_end,
|
|
)
|
|
|
|
def test_planned_output_path_uses_unique_timestamp_name(self) -> None:
|
|
probe = make_probe(
|
|
timestamps=[0.0],
|
|
duration=1.0,
|
|
metadata_end=datetime(2026, 6, 2, 12, 0, 1, tzinfo=timezone.utc),
|
|
)
|
|
timeline = OutputTimeline(
|
|
frames=[
|
|
OutputFrameTiming(
|
|
output_index=0,
|
|
source_id=1,
|
|
source_frame=0,
|
|
start=0.0,
|
|
duration=1.0,
|
|
)
|
|
],
|
|
audio_segments=[(0.0, 1.0)],
|
|
consumed_source_frames=[0],
|
|
duration_seconds=1.0,
|
|
timing_kind="unknown",
|
|
beginning_trimmed=False,
|
|
ending_trimmed=False,
|
|
dropped_frame_count=0,
|
|
source_frame_count=1,
|
|
matrix=None,
|
|
)
|
|
|
|
path = planned_output_path(
|
|
script=Path("video_workspace/source.mp4.avs"),
|
|
timeline=timeline,
|
|
probe=probe,
|
|
naming=OutputNamingOptions(use_timestamps=True, timezone_value=timezone.utc),
|
|
extension=".mp4",
|
|
planned_outputs=set(),
|
|
)
|
|
|
|
self.assertEqual(path, Path("video_workspace/20260602_120000.mp4"))
|
|
|
|
def test_planned_output_path_adds_suffix_for_same_batch_collision(self) -> None:
|
|
probe = make_probe(
|
|
timestamps=[0.0],
|
|
duration=1.0,
|
|
metadata_end=datetime(2026, 6, 2, 12, 0, 1, tzinfo=timezone.utc),
|
|
)
|
|
timeline = OutputTimeline(
|
|
frames=[OutputFrameTiming(0, 1, 0, 0.0, 1.0)],
|
|
audio_segments=[(0.0, 1.0)],
|
|
consumed_source_frames=[0],
|
|
duration_seconds=1.0,
|
|
timing_kind="unknown",
|
|
beginning_trimmed=False,
|
|
ending_trimmed=False,
|
|
dropped_frame_count=0,
|
|
source_frame_count=1,
|
|
matrix=None,
|
|
)
|
|
planned_outputs: set[Path] = set()
|
|
first = planned_output_path(
|
|
script=Path("video_workspace/one.mp4.avs"),
|
|
timeline=timeline,
|
|
probe=probe,
|
|
naming=OutputNamingOptions(use_timestamps=True, timezone_value=timezone.utc),
|
|
extension=".mp4",
|
|
planned_outputs=planned_outputs,
|
|
)
|
|
second = planned_output_path(
|
|
script=Path("video_workspace/two.mp4.avs"),
|
|
timeline=timeline,
|
|
probe=probe,
|
|
naming=OutputNamingOptions(use_timestamps=True, timezone_value=timezone.utc),
|
|
extension=".mp4",
|
|
planned_outputs=planned_outputs,
|
|
)
|
|
|
|
self.assertEqual(first, Path("video_workspace/20260602_120000.mp4"))
|
|
self.assertEqual(second, Path("video_workspace/20260602_120000-1.mp4"))
|
|
|
|
def test_planned_output_path_avoids_existing_file_when_not_overwriting(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
directory = Path(temp_dir)
|
|
script = directory / "source.mp4.avs"
|
|
existing = directory / "20260602_120000.mp4"
|
|
existing.touch()
|
|
probe = make_probe(
|
|
timestamps=[0.0],
|
|
duration=1.0,
|
|
metadata_end=datetime(2026, 6, 2, 12, 0, 1, tzinfo=timezone.utc),
|
|
)
|
|
timeline = OutputTimeline(
|
|
frames=[OutputFrameTiming(0, 1, 0, 0.0, 1.0)],
|
|
audio_segments=[(0.0, 1.0)],
|
|
consumed_source_frames=[0],
|
|
duration_seconds=1.0,
|
|
timing_kind="unknown",
|
|
beginning_trimmed=False,
|
|
ending_trimmed=False,
|
|
dropped_frame_count=0,
|
|
source_frame_count=1,
|
|
matrix=None,
|
|
)
|
|
|
|
path = planned_output_path(
|
|
script=script,
|
|
timeline=timeline,
|
|
probe=probe,
|
|
naming=OutputNamingOptions(use_timestamps=True, timezone_value=timezone.utc),
|
|
extension=".mp4",
|
|
planned_outputs=set(),
|
|
overwrite_existing=False,
|
|
)
|
|
|
|
self.assertEqual(path, directory / "20260602_120000-1.mp4")
|
|
|
|
|
|
class AvisynthRunnerTests(unittest.TestCase):
|
|
def test_32bit_marker_allows_whitespace_and_hyphen(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
script = Path(temp_dir) / "edit.avs"
|
|
script.write_text("last\n\n # 32 - bit \n \n", encoding="utf-8")
|
|
|
|
self.assertTrue(script_requests_32bit(script))
|
|
|
|
def test_32bit_marker_only_matches_last_nonblank_line(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
script = Path(temp_dir) / "edit.avs"
|
|
script.write_text("#32bit\nlast\n", encoding="utf-8")
|
|
|
|
self.assertFalse(script_requests_32bit(script))
|
|
|
|
def test_bare_bundled_windows_runner_name_resolves(self) -> None:
|
|
runner = Path("tools/avisynth_runner/mbt_avs_runner.exe")
|
|
if not runner.is_file():
|
|
self.skipTest("bundled Windows runner is not present")
|
|
|
|
self.assertEqual(validate_runner_path("mbt_avs_runner.exe"), runner.resolve())
|
|
|
|
|
|
class ExecutableLookupTests(unittest.TestCase):
|
|
def test_windows_mkvmerge_default_is_used_before_prompt(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
executable = Path(temp_dir) / "mkvmerge.exe"
|
|
executable.touch()
|
|
output = StringIO()
|
|
with (
|
|
patch("tools.executables.which", return_value=None),
|
|
patch(
|
|
"tools.executables._windows_default_executable",
|
|
return_value=executable.resolve(),
|
|
),
|
|
patch("tools.executables.sys.stdin.isatty", return_value=True),
|
|
redirect_stdout(output),
|
|
):
|
|
self.assertEqual(require_executable("mkvmerge"), str(executable.resolve()))
|
|
self.assertIn("using", output.getvalue())
|
|
|
|
def test_missing_tool_accepts_an_absolute_path_for_this_run(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
executable = Path(temp_dir) / "mkvmerge.exe"
|
|
executable.touch()
|
|
output = StringIO()
|
|
with (
|
|
patch("tools.executables.which", return_value=None),
|
|
patch("tools.executables.sys.stdin.isatty", return_value=True),
|
|
patch("tools.executables.prompt_input", return_value=str(executable)),
|
|
redirect_stdout(output),
|
|
):
|
|
self.assertEqual(require_executable("mkvmerge"), str(executable.resolve()))
|
|
|
|
def test_missing_tool_rejects_relative_path_before_accepting_absolute_path(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
executable = Path(temp_dir) / "mkvmerge.exe"
|
|
executable.touch()
|
|
output = StringIO()
|
|
with (
|
|
patch("tools.executables.which", return_value=None),
|
|
patch("tools.executables.sys.stdin.isatty", return_value=True),
|
|
patch(
|
|
"tools.executables.prompt_input",
|
|
side_effect=["mkvmerge.exe", str(executable)],
|
|
),
|
|
redirect_stdout(output),
|
|
):
|
|
self.assertEqual(require_executable("mkvmerge"), str(executable.resolve()))
|
|
self.assertIn("Please enter an absolute path.", output.getvalue())
|
|
|
|
|
|
class AvisynthWorkspaceTests(unittest.TestCase):
|
|
def test_relative_timestamp_label_switches_to_minutes_and_hours(self) -> None:
|
|
self.assertEqual(_format_relative_timestamp(4.5), "4.500000")
|
|
self.assertEqual(
|
|
_format_relative_timestamp(291.029733),
|
|
"4:51.029733 (291.029733)",
|
|
)
|
|
self.assertEqual(
|
|
_format_relative_timestamp(3891.029733),
|
|
"1:04:51.029733 (3891.029733)",
|
|
)
|
|
|
|
def test_hidden_source_handles_missing_video_only_marker(self) -> None:
|
|
probe = make_probe(timestamps=[0.0, 0.04], duration=0.08)
|
|
probe = VideoProbe(
|
|
path=probe.path,
|
|
codec=probe.codec,
|
|
width=probe.width,
|
|
height=probe.height,
|
|
pixel_format=probe.pixel_format,
|
|
bit_depth=probe.bit_depth,
|
|
video_bit_rate=probe.video_bit_rate,
|
|
color_space=probe.color_space,
|
|
color_matrix=probe.color_matrix,
|
|
expected_color_matrix=probe.expected_color_matrix,
|
|
duration_seconds=probe.duration_seconds,
|
|
frame_timestamps=probe.frame_timestamps,
|
|
stream_frame_count=probe.stream_frame_count,
|
|
audio_streams=probe.audio_streams,
|
|
audio_stream_count=1,
|
|
audio_sample_rate=48000,
|
|
avg_frame_rate=probe.avg_frame_rate,
|
|
r_frame_rate=probe.r_frame_rate,
|
|
time_base=probe.time_base,
|
|
timing=probe.timing,
|
|
metadata_end=probe.metadata_end,
|
|
metadata_begin_tag=probe.metadata_begin_tag,
|
|
source_timezone=probe.source_timezone,
|
|
computed_begin=probe.computed_begin,
|
|
filename_begin=probe.filename_begin,
|
|
gps_latitude=probe.gps_latitude,
|
|
gps_longitude=probe.gps_longitude,
|
|
warnings=probe.warnings,
|
|
)
|
|
text = _hidden_source_script(
|
|
VideoInput(Path("source.mp4"), Path("source.mp4"), Path("source.mp4")),
|
|
probe,
|
|
0,
|
|
Path("workspace/.source/source.mp4.source.avs"),
|
|
Path("tools/avisynth_helpers.avs"),
|
|
None,
|
|
Path("workspace/.source/source.mp4.timestamps.txt"),
|
|
Path("workspace/.source/source.mp4.frame-times.txt"),
|
|
Path("workspace/.source/source.mp4.frame-time-labels.txt"),
|
|
Path("workspace/.source/source.mp4.durations.txt"),
|
|
Path("workspace/.source/source.mp4.duration-labels.txt"),
|
|
)
|
|
|
|
self.assertIn("try {\n mbt_video_only = mbt_video_only", text)
|
|
self.assertIn("} catch (err_msg) {\n mbt_video_only = false", text)
|
|
self.assertIn("mbt_validation_blank = mbt_validation_blank", text)
|
|
self.assertIn("BlankClip(video, color=$000000)", text)
|
|
self.assertIn("global mbt_hdr_peak = 0.000000", text)
|
|
self.assertNotIn('Defined("mbt_video_only")', text)
|
|
|
|
def test_hidden_source_marks_timing_after_conditional_readers(self) -> None:
|
|
probe = make_probe(timestamps=[0.0, 0.04], duration=0.08)
|
|
text = _hidden_source_script(
|
|
VideoInput(Path("source.mp4"), Path("source.mp4"), Path("source.mp4")),
|
|
probe,
|
|
0,
|
|
Path("workspace/.source/source.mp4.source.avs"),
|
|
Path("tools/avisynth_helpers.avs"),
|
|
None,
|
|
Path("workspace/.source/source.mp4.timestamps.txt"),
|
|
Path("workspace/.source/source.mp4.frame-times.txt"),
|
|
Path("workspace/.source/source.mp4.frame-time-labels.txt"),
|
|
Path("workspace/.source/source.mp4.durations.txt"),
|
|
Path("workspace/.source/source.mp4.duration-labels.txt"),
|
|
)
|
|
|
|
self.assertLess(text.index("MBT_MarkSource"), text.index("ConditionalReader"))
|
|
self.assertGreater(text.index("MBT_MarkTiming(last)"), text.rindex("ConditionalReader"))
|
|
|
|
def test_validation_wrapper_keeps_audio_for_clip_summary(self) -> None:
|
|
from tools.avisynth_workspace import _validation_script
|
|
|
|
text = _validation_script(Path("edit.avs"), Path("source.validate.avs"))
|
|
|
|
self.assertNotIn("mbt_video_only = true", text)
|
|
self.assertNotIn("mbt_validation_blank", text)
|
|
|
|
|
|
class AvisynthRenderTests(unittest.TestCase):
|
|
def test_complete_csv_can_recover_from_renderer_cleanup_crash(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
temp = Path(temp_dir)
|
|
renderer = temp / "renderer.py"
|
|
csv_path = temp / "frames.csv"
|
|
script = temp / "validate.avs"
|
|
script.write_text("last\n", encoding="utf-8")
|
|
renderer.write_text(
|
|
"\n".join(
|
|
[
|
|
"from pathlib import Path",
|
|
"import sys",
|
|
"csv = Path(sys.argv[1])",
|
|
"csv.write_text('output_frame,source_id,source_frame,drop_frame\\n0,1,0,0\\n', encoding='utf-8')",
|
|
"print('clip_info,width=16,height=16,frames=1,fps_num=1,fps_den=1,has_audio=0,audio_rate=0,audio_samples=0')",
|
|
"print('rendered=1')",
|
|
"raise SystemExit(7)",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
run = run_validation_script(
|
|
renderer_command=["python3", str(renderer), str(csv_path)],
|
|
script=script,
|
|
csv_path=csv_path,
|
|
expected_source_ids={1},
|
|
)
|
|
|
|
self.assertTrue(run.recovered_from_renderer_error)
|
|
self.assertEqual(run.renderer_returncode, 7)
|
|
self.assertTrue(run.result.accepted)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|