Enhance Avisynth video workflow and metadata tools
This commit is contained in:
+450
-29
@@ -1,16 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
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
|
||||
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,
|
||||
@@ -20,16 +26,22 @@ 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 (
|
||||
missing_source_frames_between_kept,
|
||||
has_avisynth_speed_changes,
|
||||
normal_deleted_frame_count,
|
||||
preserves_entire_source_timeline,
|
||||
y4m_command_for_timeline,
|
||||
@@ -44,25 +56,45 @@ from tools.video_codec_constraints import (
|
||||
resolve_profile,
|
||||
)
|
||||
from tools.video_inputs import VideoInput
|
||||
from tools.video_options import OutputNamingOptions
|
||||
from tools.video_outputs import output_begin_timestamp, planned_output_path
|
||||
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, _video_timezone, summarize_frame_timing
|
||||
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
|
||||
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, wait_for_script_edits
|
||||
from video_encode import (
|
||||
print_workspace_summary,
|
||||
stale_workspace_manifests,
|
||||
wait_for_script_edits,
|
||||
)
|
||||
|
||||
|
||||
def make_probe(
|
||||
@@ -112,6 +144,13 @@ def make_probe(
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
@@ -215,6 +254,39 @@ class VideoCodecConstraintTests(unittest.TestCase):
|
||||
|
||||
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)),
|
||||
@@ -265,7 +337,53 @@ class VideoCodecConstraintTests(unittest.TestCase):
|
||||
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"
|
||||
@@ -358,20 +476,41 @@ class VideoTimelineTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
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"),
|
||||
options=VideoCodecOptions(
|
||||
codec="libx264",
|
||||
crf=18,
|
||||
preset="slow",
|
||||
profile="high10",
|
||||
level="4.1",
|
||||
threads=3,
|
||||
),
|
||||
pixel_format="yuv422p10le",
|
||||
colorspace="bt709",
|
||||
)
|
||||
|
||||
self.assertIn("--tcfile-in", command)
|
||||
@@ -390,6 +529,91 @@ class VideoTimelineTests(unittest.TestCase):
|
||||
|
||||
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"
|
||||
@@ -545,6 +769,83 @@ class VideoTimelineTests(unittest.TestCase):
|
||||
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)],
|
||||
@@ -661,32 +962,66 @@ class VideoTimelineTests(unittest.TestCase):
|
||||
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({"timestamp_names": False}, has_speed_changes=False),
|
||||
_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({}, has_speed_changes=False)
|
||||
steps = _encoding_steps(make_answers(), has_speed_changes=False)
|
||||
|
||||
self.assertEqual(steps[steps.index("level") + 1], "threads")
|
||||
self.assertEqual(_parse_answer("threads", "auto", {}, []), "auto")
|
||||
self.assertEqual(_parse_answer("threads", "2", {}, []), 2)
|
||||
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", {}, [])
|
||||
_parse_answer("threads", "0", make_answers(), [])
|
||||
|
||||
def test_default_crf_depends_on_codec(self) -> None:
|
||||
self.assertEqual(_default_answer("crf", {"codec": "libx264"}, []), 16)
|
||||
self.assertEqual(_default_answer("crf", {"codec": "libx265"}, []), 21)
|
||||
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", {}, [])
|
||||
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", {}, [])
|
||||
question = _question_for_key("preset", make_answers(), [])
|
||||
|
||||
self.assertIn("ultrafast/superfast/veryfast", question)
|
||||
self.assertIn("slow/slower/veryslow/placebo", question)
|
||||
@@ -721,7 +1056,6 @@ class VideoTimelineTests(unittest.TestCase):
|
||||
matrix=None,
|
||||
)
|
||||
|
||||
self.assertEqual(missing_source_frames_between_kept(timeline), 1)
|
||||
self.assertEqual(normal_deleted_frame_count(timeline), 0)
|
||||
|
||||
def test_normal_frame_deletion_requires_audio_cut_segments(self) -> None:
|
||||
@@ -744,7 +1078,6 @@ class VideoTimelineTests(unittest.TestCase):
|
||||
matrix=None,
|
||||
)
|
||||
|
||||
self.assertEqual(missing_source_frames_between_kept(timeline), 2)
|
||||
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)])
|
||||
|
||||
@@ -791,6 +1124,25 @@ class AudioFilterTests(unittest.TestCase):
|
||||
|
||||
|
||||
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],
|
||||
@@ -823,6 +1175,55 @@ class OutputNamingTests(unittest.TestCase):
|
||||
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],
|
||||
@@ -1071,8 +1472,28 @@ class AvisynthWorkspaceTests(unittest.TestCase):
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user