From 49296d6f459b2891ee8b928309ef5a8158f57195 Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Tue, 21 Jul 2026 00:41:00 +0000 Subject: [PATCH] Add Avisynth video encoding workflow --- .gitignore | 3 + README.md | 139 ++- copy_metadata.py | 47 +- tools/avisynth_helpers.avs | 341 +++++++ tools/avisynth_render.py | 231 +++++ tools/avisynth_runner.py | 110 +++ tools/avisynth_runner/build_linux.sh | 21 + tools/avisynth_runner/build_windows.sh | 38 + tools/avisynth_runner/mbt_avs_runner.c | 853 ++++++++++++++++ tools/avisynth_runner/mbt_avs_runner.exe | Bin 0 -> 273938 bytes tools/avisynth_runner/mbt_avs_runner32.exe | Bin 0 -> 261445 bytes tools/avisynth_validate.py | 157 +++ tools/avisynth_workspace.py | 501 ++++++++++ tools/console.py | 327 +++++++ tools/executables.py | 50 + tools/exiftool.py | 72 +- tools/filenames.py | 14 +- tools/metadata_copy.py | 139 +++ tools/timezones.py | 36 + tools/video_batch_encode.py | 999 +++++++++++++++++++ tools/video_codec_constraints.py | 222 +++++ tools/video_encode_output.py | 641 ++++++++++++ tools/video_encode_plan.py | 131 +++ tools/video_formatting.py | 119 +++ tools/video_inputs.py | 111 +++ tools/video_options.py | 412 ++++++++ tools/video_outputs.py | 314 ++++++ tools/video_probe.py | 523 ++++++++++ tools/video_reporting.py | 374 +++++++ tools/video_timeline.py | 169 ++++ tools/video_timestamp_encode.py | 359 +++++++ tools/video_validation_workflow.py | 246 +++++ unit_tests/test_exiftool.py | 39 + unit_tests/test_video_e2e.py | 427 ++++++++ unit_tests/test_video_logic.py | 1034 ++++++++++++++++++++ video_encode.py | 354 +++++++ view_metadata.py | 10 +- 37 files changed, 9487 insertions(+), 76 deletions(-) create mode 100644 tools/avisynth_helpers.avs create mode 100644 tools/avisynth_render.py create mode 100644 tools/avisynth_runner.py create mode 100755 tools/avisynth_runner/build_linux.sh create mode 100755 tools/avisynth_runner/build_windows.sh create mode 100644 tools/avisynth_runner/mbt_avs_runner.c create mode 100755 tools/avisynth_runner/mbt_avs_runner.exe create mode 100755 tools/avisynth_runner/mbt_avs_runner32.exe create mode 100644 tools/avisynth_validate.py create mode 100644 tools/avisynth_workspace.py create mode 100644 tools/executables.py create mode 100644 tools/metadata_copy.py create mode 100644 tools/timezones.py create mode 100644 tools/video_batch_encode.py create mode 100644 tools/video_codec_constraints.py create mode 100644 tools/video_encode_output.py create mode 100644 tools/video_encode_plan.py create mode 100644 tools/video_formatting.py create mode 100644 tools/video_inputs.py create mode 100644 tools/video_options.py create mode 100644 tools/video_outputs.py create mode 100644 tools/video_probe.py create mode 100644 tools/video_reporting.py create mode 100644 tools/video_timeline.py create mode 100644 tools/video_timestamp_encode.py create mode 100644 tools/video_validation_workflow.py create mode 100644 unit_tests/test_exiftool.py create mode 100644 unit_tests/test_video_e2e.py create mode 100644 unit_tests/test_video_logic.py create mode 100644 video_encode.py diff --git a/.gitignore b/.gitignore index b1cba03..afcb0a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ old/ tests/ PLAN.md +VIDEO_PLAN.md __pycache__/ +video_workspace/ +tools/avisynth_runner/mbt_avs_runner diff --git a/README.md b/README.md index 58a126a..4e01999 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,135 @@ If run without files, it asks for source and destination paths. If exactly two f The script copies normal metadata with ExifTool `-TagsFromFile`, while excluding file/system tags, composite tags, image dimensions, pixel dimensions, and resolution fields that should remain specific to the destination file. +### `video_encode.py` + +Probe video files for the future editing and encoding workflow: + +```bash +python3 video_encode.py path/to/file/or/directory [...] +``` + +This script currently leaves source videos untouched. It recursively collects supported videos, builds the collapsed drag tree, prints probe data from `ffprobe` and `exiftool`, creates the Avisynth workspace layout under `video_workspace/`, pauses for edits, then validates the edited scripts before optional encoding. + +Current probe output includes: + +- codec, dimensions, duration, frame count, stream rates, and time base +- CFR/VFR classification based on frame timestamps +- beginning-to-end timestamp range, using an embedded source timezone such as Samsung `AndroidTimeZone` or Sony `TimeZone` when available, otherwise UTC +- rounded-duration beginning timestamp, matching the photo metadata script's video naming rule + +Current workspace output includes: + +- editable scripts in `video_workspace/` +- generated hidden source scripts in `video_workspace/.source/` +- generated validation wrappers in `video_workspace/.source/` +- per-frame `ConditionalReader` data for timestamps and durations + +Existing editable `.avs` scripts are not overwritten unless you confirm it. + +After creating the workspace, the script waits while you edit the visible `.avs` files. Press Enter for fast blank-frame validation, type `r` for real-frame validation, or type `q` to stop after workspace creation. + +The hidden source scripts currently target FFMS2's `FFVideoSource`, import bundled helpers, and mark source frames with pure Avisynth+ `propSet` frame identity properties. By default, generated scripts rely on Avisynth autoload for FFMS2. Set `MBT_FFMS2_PLUGIN` to a plugin path to generate `LoadPlugin("...")`, or set `MBT_ASK_FFMS2_PLUGIN=1` to ask for that path interactively. + +Python keeps the probed timestamp table and later maps output frames back to source timing from `mbt_source_id` and `mbt_source_frame`. + +The bundled helpers currently include `MBT_Drop`, `MBT_Undrop`, `MBT_DropEvery`, `MBT_UndropEvery`, `MBT_Info`, `Resize`, `RotateCrop`, `MBT_CorrectMatrix`, `Cropf`, `DeShake`, and `FixContrast`. `RotateCrop(angle, dar)` uses the external manyPlus `Turn` function, then crops the largest rectangle of the requested aspect ratio that contains no rotation background; `dar` defaults to the source aspect ratio. Source scripts inject the detected matrix into the standard `_Matrix` frame property. `Resize` reads `_Matrix` when `input_matrix` is not supplied, chooses an output matrix from the output resolution, updates `_Matrix`, and auto-enables linear-light resizing when either dimension is downscaled by 1.5x or more. The linear path requires `y_gamma_to_linear`/`y_linear_to_gamma`; pass `linear=false` or `linear=true` to override that decision. `DeShake` still requires the usual Avisynth stabilization plugins to be available if you call it. + +Generated editable `.avs` files include source size/chroma/bitdepth, frame count, frame rate, duration, and timing classification. The hidden source scripts also generate `ConditionalReader` sidecar files for each source frame's relative video time, absolute timestamp, and frame duration, then attach those values as frame properties. Use `MBT_Info(last)` while editing to overlay the user-facing frame properties. The internal `mbt_source_id` property is still present for batch validation, but it is not shown by that helper. + +Validation wrappers are named `[original filename].validate.avs`. Fast validation is the default: the runner sets an in-memory AviSynth variable before importing the wrapper, making the hidden source use a `BlankClip` with the source clip's format, frame count, and frame rate. This exercises frame selection/properties without decoding every source image. Real-frame validation uses the same wrapper but disables that variable. The bundled runner writes a temporary frame table containing `output_frame,source_id,source_frame,drop_frame,matrix`; Python reads it to check that frame identity survived the edited script, then removes it after successful validation. + +Set `MBT_ENCODE_VIDEO=1` to encode after validation. This milestone encodes accepted CFR timelines, VFR timelines, CFR speed changes, `MBT_Drop` output, normal frame deletion such as `SelectEven()`, and mixed deletion/drop edits. When normal frame deletion removes source time, AAC audio is cut into matching segments before encoding; `MBT_Drop` durations keep audio continuous. + +Validation also reports the actual Avisynth output frame rate and playback duration. If the script changes playback speed, for example with `AssumeFPS`, AAC audio is tempo-adjusted to match the encoded video duration. + +After validation, interactive runs switch to an encoding review screen. The top of the screen lists the validated clips and edited beginning timestamps in UTC; below it, answered encoding settings are shown in a small table. Type `b` to go back. If a setting changes another setting's meaning, such as switching between x264 and x265, dependent answers such as CRF/profile/level are cleared and return to their codec-specific defaults. + +x265 is the default video encoder. Profile and level can each be left unset, calculated as the minimum required for every clip individually, or selected manually as a requested floor. A manual value that is too low for one clip is upgraded only for that clip. Choices shown in red do not directly support every clip in the batch. x264 is unavailable when any validated Avisynth output is above 10-bit; output above 12-bit fails validation and is excluded from encoding. For x265, tier follows the level constraint: x265 tries Main tier first and may use High tier when the selected level requires it. + +The repo includes source for a small bundled Avisynth runner in `tools/avisynth_runner/`. Build it on Linux with: + +```bash +tools/avisynth_runner/build_linux.sh +``` + +When `tools/avisynth_runner/mbt_avs_runner` exists, `video_encode.py` uses it automatically for validation, Y4M video piping, timeline-preserving `drop_frame` omission, and optional WAV audio rendering. Encoding shows the Avisynth rendering and encoder-output progress separately, so encoder lookahead is visible. To use a runner from another location, set `MBT_AVS_RUNNER` to that executable. + +On Windows, the bundled 64-bit runner is `tools/avisynth_runner/mbt_avs_runner.exe`. It loads the installed 64-bit AviSynth+ runtime at execution time, so AviSynth+ still needs to be installed. The script automatically tries the bundled 64-bit runner first. If a bundled or configured 32-bit runner exists, it can be selected for a specific editable `.avs` by ending that file with a commented marker like `#32bit` or `# 32-bit`. Trailing blank lines are ignored. To override runner locations, set `MBT_AVS_RUNNER64`, `MBT_AVS_RUNNER32`, or `MBT_AVS_RUNNER`. + +Piping Y4M through the bundled runner does not add video quality loss, because Y4M is uncompressed video. The tradeoff is that a raw video pipe does not carry audio, metadata, frame properties, or arbitrary per-frame timestamps; those are handled separately by Python. FFmpeg is used for encoding and muxing, not as the Avisynth host. + +Audio is not piped together with Y4M video. By default, FFmpeg reads audio from the original source file and the Python tool applies the validated trim/speed plan through FFmpeg filters. If an optional `[visible script stem]_audio.avs` exists, the runner renders that script's audio to a temporary WAV with `--wav`, and FFmpeg uses that WAV as the audio source. This keeps video piping simple while still allowing Avisynth-based audio edits. + +For VFR output or normal frame deletion, the tool writes one timestamp-v2 sidecar and keeps only a compressed temporary video. x264 uses a raw H.264 intermediate and receives those timestamps during encoding through `--tcfile-in`, so its CRF rate control sees individual frame durations. x265 has no equivalent VFR rate-control input, so it encodes an intermediate Matroska video at the edited timeline's average frame rate. MKVToolNix then applies the exact frame timestamps to either compressed stream, and FFmpeg stream-copies the timed video while adding audio and writing the requested MP4 or MKV container. This avoids the previous temporary PNG sequence. + +Timestamp-driven output requires `mkvmerge` on `PATH`. On Windows, the standard `C:\Program Files\MKVToolNix\mkvmerge.exe` installation is also detected automatically. Timestamp-driven x264 output also requires the `x264` command-line encoder on `PATH`; CFR x264 output continues to use FFmpeg's `libx264` encoder. + +In an interactive run, if an external executable such as `mkvmerge`, `x264`, `ffmpeg`, `ffprobe`, or `exiftool` is missing from `PATH` and has no supported default location, the script asks for its absolute path. That path is used for the rest of the current batch only, so a late missing-tool discovery does not discard the validated workspace or encoding choices. + +When encoding, the script can name outputs from the edited beginning timestamp, using nearest-second rounding and a chosen timezone. Before encoding begins, existing output names are listed and can be overwritten (default) or assigned `-1`, `-2`, and so on. Same-batch naming collisions always use that suffixing. Source metadata, including location metadata, is copied to the output while excluding physical media properties such as dimensions, duration, bitrate, frame rate, codec, and audio layout. MP4 output then gets video container timestamp tags written to the edited end timestamp in UTC. The encoded files are written next to the editable `.avs` scripts under `video_workspace/`. + +Audio support currently covers validated outputs that the current encoder path supports: + +- AAC re-encode trims audio to the validated output span and tempo-adjusts it when the Avisynth output duration changes. +- Normal frame deletion uses segmented AAC audio cuts so removed frame durations are removed from audio too. +- Audio copy is allowed for whole-file outputs and simple beginning/end trims. FFmpeg does input-side stream-copy trimming for that case, so the cut can be limited by packet/keyframe boundaries. Normal frame deletion and speed changes still require audio re-encoding or no audio. +- If copy is requested for a trimmed or speed-changed output, the output is video-only rather than risking bad sync. +- MKV output can use Opus or FLAC audio. MP4 output intentionally stays limited to AAC, copy, or no audio for compatibility. +- Optional audio edits are supported by copying the editable video `.avs` to `[visible script stem]_audio.avs` next to it. The normal generated source import already includes source audio when the video has audio, so the audio edit script can be the same script shape as the video edit script. Its video output is ignored, and its whole audio output is rendered to WAV, then trimmed by Python to match the validated video timeline. + +After successful encodes, the script asks what to do with the finished files: + +- leave them in `video_workspace/` +- move them to the source directories and delete the originals +- move them to the source directories and move originals into `video_workspace/originals/` + +Non-interactive encode options: + +- `MBT_VIDEO_TIMESTAMP_NAMES=1` names outputs as `YYYYMMDD_HHMMSS.mp4`; `0` keeps original-style workspace names. +- `MBT_VIDEO_TIMEZONE=+03:00` selects the timezone used for timestamp filenames. If omitted, one consistent embedded source timezone is preferred; conflicting or missing source offsets fall back to the system timezone. No timezone is needed when timestamp filenames are disabled. +- `MBT_VIDEO_CONTAINER=mp4` or `mkv` selects the output container. MP4 is the default. +- `MBT_VIDEO_CODEC=x264` or `x265` selects FFmpeg `libx264` or `libx265`; x265 is the default. +- `MBT_VIDEO_CRF=16` sets CRF. Interactive defaults are 16 for x264 and 21 for x265. +- `MBT_VIDEO_PRESET=slow` sets the encoder preset. +- `MBT_VIDEO_PROFILE=minimum` and `MBT_VIDEO_LEVEL=minimum` calculate per-clip constraints. A named profile or level acts as a requested floor. Omit either variable to apply no constraint for it. +- `MBT_VIDEO_THREADS=auto` (the default) leaves encoder thread selection automatic. Set it to a positive integer to limit x264 threads or x265 worker pools. +- `MBT_VIDEO_AUDIO=aac`, `opus`, `flac`, `copy`, or `none` selects audio handling. Opus and FLAC require MKV. +- `MBT_VIDEO_AUDIO_BITRATE=192k` sets AAC bitrate. +- `MBT_VIDEO_AUDIO_PITCH=preserve` or `shift` selects whether speed-changed AAC audio keeps pitch or shifts pitch with playback speed. The default is `preserve`. +- `MBT_VIDEO_FINAL_ACTION=leave`, `replace`, or `backup` selects the final disposition. +- `MBT_VIDEO_DELETE_WORKSPACE=1` deletes `video_workspace` after moving outputs. + +Supported initial video extensions are `.mp4`, `.mov`, `.mkv`, `.mts`, `.m2ts`, and `.avi`. + +## Testing + +Run the Python tests with: + +```bash +python3 -m unittest discover -s unit_tests +``` + +The pure Python tests always run. The video end-to-end scenario tests skip unless the local test fixtures and toolchain are available: `tests/`, ExifTool, FFmpeg/FFprobe, FFMS2 for Avisynth, and the bundled `mbt_avs_runner`. + +Current video scenarios copy fixture videos to temporary directories before processing and validate: + +- CFR MP4 encode with AAC audio and metadata copy +- VFR MP4 trim with copied GPS metadata and adjusted end timestamp +- normal frame deletion such as `SelectEven()` with segmented AAC audio +- mixed normal frame deletion plus `MBT_Drop` +- `MBT_Drop(start,end)` and `MBT_DropEvery(...)` helper behavior +- MKV output with Opus audio + ## Requirements - Python 3.10 or newer - ExifTool available as `exiftool` on `PATH` +- FFmpeg available as `ffmpeg` and `ffprobe` on `PATH` for `video_encode.py` +- MKVToolNix available as `mkvmerge` on `PATH` for VFR or frame-deletion output (Windows also checks its standard installation path) +- the x264 command-line encoder available as `x264` on `PATH` for timestamp-aware x264 output +- FFMS2 available to Avisynth as `FFVideoSource` before generated `.avs` scripts can load real videos, either through plugin autoload or a `LoadPlugin` path +- AviSynth+ development files and a C compiler are needed only if building the bundled runner from source On Debian/Ubuntu-like Linux systems, ExifTool is commonly installed with: @@ -104,7 +229,13 @@ On Debian/Ubuntu-like Linux systems, ExifTool is commonly installed with: sudo apt install libimage-exiftool-perl ``` -On Windows, install ExifTool and make sure the `exiftool` command is available in a normal terminal. +FFmpeg/FFprobe is commonly installed with: + +```bash +sudo apt install ffmpeg x264 mkvtoolnix +``` + +On Windows, install ExifTool and make sure the `exiftool` command is available in a normal terminal. For timestamp-driven video output, also install MKVToolNix; the usual `C:\Program Files\MKVToolNix\mkvmerge.exe` location is detected automatically, otherwise add `mkvmerge` to `PATH`. Timestamp-aware x264 output additionally needs the x264 command-line encoder as `x264` on `PATH`. No virtual environment is required. The scripts use only the Python standard library plus external command-line tools. @@ -112,11 +243,13 @@ No virtual environment is required. The scripts use only the Python standard lib `photo_metadata.py` edits files in place, but it prints a preview and asks for confirmation before writing metadata or moving files. Test media should be copied to a temporary directory before running destructive checks. -`tests/`, `old/`, and `PLAN.md` are intentionally ignored by git. The `tests/` directory is for reusable source fixtures that should not be modified directly. +`video_encode.py` creates files under `video_workspace/`, including encoded outputs when requested. It only modifies source directories or source videos if you explicitly choose a final action that moves outputs back to the source folders. + +`tests/`, `old/`, `PLAN.md`, `VIDEO_PLAN.md`, and `video_workspace/` are intentionally ignored by git. The `tests/` directory is for reusable source fixtures that should not be modified directly. ## Later Work -Separate video tools may be added later for remuxing, re-encoding, audio extraction/conversion, and timecode handling. Those features are intentionally not mixed into `photo_metadata.py`. +`video_encode.py` will grow into the separate video tool for Avisynth editing, remuxing, re-encoding, audio handling, and timecode handling. Those features are intentionally not mixed into `photo_metadata.py`. ## AI Note diff --git a/copy_metadata.py b/copy_metadata.py index 695a94a..cf75531 100644 --- a/copy_metadata.py +++ b/copy_metadata.py @@ -8,43 +8,12 @@ onto the script and choose which is the source. from __future__ import annotations -import subprocess import sys from pathlib import Path from tools.console import pause_if_interactive, prompt_input from tools.exiftool import require_exiftool - - -EXCLUDED_COPY_TAGS = [ - "--File:all", - "--ExifTool:all", - "--Composite:all", - "--SourceFile", - "--Directory", - "--FileName", - "--FileSize", - "--FileModifyDate", - "--FileAccessDate", - "--FileInodeChangeDate", - "--FilePermissions", - "--FileType", - "--FileTypeExtension", - "--MIMEType", - "--ImageWidth", - "--ImageHeight", - "--ExifImageWidth", - "--ExifImageHeight", - "--PixelXDimension", - "--PixelYDimension", - "--RelatedImageWidth", - "--RelatedImageHeight", - "--ImageSize", - "--Megapixels", - "--XResolution", - "--YResolution", - "--ResolutionUnit", -] +from tools.metadata_copy import copy_meaningful_metadata def prompt_path(prompt: str) -> Path: @@ -93,18 +62,8 @@ def resolve_files(argv: list[str]) -> tuple[Path, Path] | None: def copy_metadata(exiftool: str, source: Path, destination: Path) -> int: print(f"Source: {source}") print(f"Destination: {destination}", flush=True) - command = [ - exiftool, - "-overwrite_original", - "-TagsFromFile", - str(source), - "-all:all", - *EXCLUDED_COPY_TAGS, - "-charset", - "filename=UTF8", - str(destination), - ] - return subprocess.run(command).returncode + copy_meaningful_metadata(exiftool, source, destination) + return 0 def run(argv: list[str]) -> int: diff --git a/tools/avisynth_helpers.avs b/tools/avisynth_helpers.avs new file mode 100644 index 0000000..7979584 --- /dev/null +++ b/tools/avisynth_helpers.avs @@ -0,0 +1,341 @@ +# media-batch-tools Avisynth+ helpers. +# +# This file is imported by generated hidden source scripts. Keep functions here +# small and dependency-light unless the visible scripts explicitly document the +# required plugins. + +function MBT_MarkSource(clip c, int source_id, int "matrix") +{ + # Attach source identity to each frame. Python reads these properties after + # running the user-edited script and maps frames back to ffprobe timestamps. + # Timestamp/duration variables are supplied by generated ConditionalReader + # files when available. + matrix = Default(matrix, MBT_GetMatrixCode(c)) + runtime = """ + last.propSet("mbt_source_id", """ + String(source_id) + """).\ + propSet("mbt_source_frame", current_frame).\ + propSet("mbt_drop_frame", 0).\ + propSet("_Matrix", """ + String(matrix) + """).\ + propSet("mbt_frame_time_seconds", mbt_frame_time_seconds).\ + propSet("mbt_relative_timestamp", mbt_relative_timestamp).\ + propSet("mbt_absolute_timestamp", mbt_absolute_timestamp).\ + propSet("mbt_frame_duration_seconds", mbt_frame_duration_seconds).\ + propSet("mbt_frame_duration", mbt_frame_duration) + """ + return c.ScriptClip(runtime) +} + +function MBT_AudioSource(clip video, string source_path, string audio_cache_path) +{ + audio = FFAudioSource(source_path, cachefile=audio_cache_path) + return AudioDub(video, audio) +} + +function MBT_Drop(clip c, int start, int "end") +{ + return MBT_SetDrop(c, 1, start, end) +} + +function MBT_Undrop(clip c, int start, int "end") +{ + return MBT_SetDrop(c, 0, start, end) +} + +function MBT_SetDrop(clip c, int value, int start, int "end") +{ + end = Default(end, -1) + end = (end < 0) ? start - end - 1 : end + runtime = """ + (current_frame >= """ + String(start) + """ && current_frame <= """ + String(end) + """) ? \ + last.propSet("mbt_drop_frame", """ + String(value) + """) : last + """ + return c.ScriptClip(runtime) +} + +function MBT_DropEvery(clip c, int cycle, int "offset0", int "offset1", int "offset2", int "offset3", int "offset4", int "offset5", int "offset6", int "offset7", int "offset8", int "offset9", int "offset10", int "offset11", int "offset12", int "offset13", int "offset14", int "offset15") +{ + return MBT_SetDropEvery(c, 1, cycle, offset0, offset1, offset2, offset3, offset4, offset5, offset6, offset7, offset8, offset9, offset10, offset11, offset12, offset13, offset14, offset15) +} + +function MBT_UndropEvery(clip c, int cycle, int "offset0", int "offset1", int "offset2", int "offset3", int "offset4", int "offset5", int "offset6", int "offset7", int "offset8", int "offset9", int "offset10", int "offset11", int "offset12", int "offset13", int "offset14", int "offset15") +{ + return MBT_SetDropEvery(c, 0, cycle, offset0, offset1, offset2, offset3, offset4, offset5, offset6, offset7, offset8, offset9, offset10, offset11, offset12, offset13, offset14, offset15) +} + +function MBT_SetDropEvery(clip c, int value, int cycle, int "offset0", int "offset1", int "offset2", int "offset3", int "offset4", int "offset5", int "offset6", int "offset7", int "offset8", int "offset9", int "offset10", int "offset11", int "offset12", int "offset13", int "offset14", int "offset15") +{ + expr = "false" + expr = Defined(offset0) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset0) + ")" : expr + expr = Defined(offset1) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset1) + ")" : expr + expr = Defined(offset2) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset2) + ")" : expr + expr = Defined(offset3) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset3) + ")" : expr + expr = Defined(offset4) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset4) + ")" : expr + expr = Defined(offset5) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset5) + ")" : expr + expr = Defined(offset6) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset6) + ")" : expr + expr = Defined(offset7) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset7) + ")" : expr + expr = Defined(offset8) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset8) + ")" : expr + expr = Defined(offset9) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset9) + ")" : expr + expr = Defined(offset10) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset10) + ")" : expr + expr = Defined(offset11) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset11) + ")" : expr + expr = Defined(offset12) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset12) + ")" : expr + expr = Defined(offset13) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset13) + ")" : expr + expr = Defined(offset14) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset14) + ")" : expr + expr = Defined(offset15) ? expr + " || (current_frame % " + String(cycle) + " == " + String(offset15) + ")" : expr + runtime = """ + (""" + expr + """) ? last.propSet("mbt_drop_frame", """ + String(value) + """) : last + """ + return c.ScriptClip(runtime) +} + +function MBT_Info(clip c, int "size", int "align") +{ + # Overlay media-batch-tools frame properties for inspection while editing. + size = Default(size, 16) + align = Default(align, 7) + runtime = """ + drop_value = propGetInt("mbt_drop_frame") == 0 ? "False" : "True" + text = "Absolute timestamp: " + propGetString("mbt_absolute_timestamp") + Chr(10) + \ + "Relative timestamp: " + propGetString("mbt_relative_timestamp") + Chr(10) + \ + Chr(10) + \ + "Source frame: " + String(propGetInt("mbt_source_frame")) + Chr(10) + \ + "Frame duration: " + propGetString("mbt_frame_duration") + Chr(10) + \ + "Drop frame: " + drop_value + Subtitle(last, text, size=""" + String(size) + """, align=""" + String(align) + """) + """ + return c.ScriptClip(runtime) +} + +function Cropf(clip c, float l, float t, float w, float h) +{ + return c.MBT_LinearResize( + \ Round(c.Width * w), + \ Round(c.Height * h), + \ c.Width * l, + \ c.Height * t, + \ c.Width * w, + \ c.Height * h + \) +} + +function RotateCrop(clip c, float angle, float "dar") +{ + dar = Default(dar, Float(c.Width) / Float(c.Height)) + radians = angle * Pi() / 180.0 + cosine = Abs(Cos(radians)) + sine = Abs(Sin(radians)) + + crop_h = Min( + \ Float(c.Height), + \ Float(c.Width) / dar, + \ Float(c.Width) / (dar * cosine + sine), + \ Float(c.Height) / (dar * sine + cosine) + \) + crop_w = crop_h * dar + + xalign = (c.Is420 || c.Is422) ? 2 : 1 + yalign = c.Is420 ? 2 : 1 + left = Ceil((c.Width - crop_w) / (2.0 * xalign)) * xalign + top = Ceil((c.Height - crop_h) / (2.0 * yalign)) * yalign + + turned = c.Turn(angle=angle) + return turned.Crop(left, top, c.Width - 2 * left, c.Height - 2 * top) +} + +function Resize(clip v, int "w", int "h", float "dar", float "xcrop", float "ycrop", int "pos", bool "linear", string "input_matrix", string "output_matrix") +{ + # Resize with optional aspect-ratio crop. + # w/h: target size. dar: display aspect ratio when one dimension is omitted. + # xcrop/ycrop: percent cropped from each side before fitting. pos uses keypad + # placement: 1 top-left, 5 center, 9 bottom-right. + wo = v.Width + ho = v.Height + + xcrop = Default(xcrop, 0.0) / 100.0 + ycrop = Default(ycrop, 0.0) / 100.0 + pos = Default(pos, 5) + pos = (pos < 1 || pos > 9) ? 5 : pos + + dar = Default(dar, Float(wo) / Float(ho)) + w = Defined(w) ? w : (Defined(h) ? Round(h * 0.5 * dar) * 2 : wo) + h = Defined(h) ? h : Round(w * 0.5 / dar) * 2 + dar = Float(w) / Float(h) + + cropped_w = (1.0 - 2.0 * xcrop) * wo + cropped_h = (1.0 - 2.0 * ycrop) * ho + + crop_extra_w = (cropped_w / cropped_h > dar) ? (cropped_w - cropped_h * dar) : 0.0 + crop_extra_h = (cropped_w / cropped_h < dar) ? (cropped_h - cropped_w / dar) : 0.0 + + crop_extra_t = crop_extra_h * ((pos <= 3) ? 0.0 : ((pos >= 7) ? 1.0 : 0.5)) + crop_extra_b = crop_extra_h * ((pos >= 7) ? 0.0 : ((pos <= 3) ? 1.0 : 0.5)) + crop_extra_l = crop_extra_w * ((pos % 3 == 1) ? 0.0 : ((pos % 3 == 0) ? 1.0 : 0.5)) + crop_extra_r = crop_extra_w * ((pos % 3 == 0) ? 0.0 : ((pos % 3 == 1) ? 1.0 : 0.5)) + + crop_l = wo * xcrop + crop_extra_l + crop_t = ho * ycrop + crop_extra_t + crop_w = cropped_w - crop_extra_l - crop_extra_r + crop_h = cropped_h - crop_extra_t - crop_extra_b + + input_matrix = Default(input_matrix, v.IsRGB ? MBT_DefaultMatrix(wo, ho) : MBT_GetMatrixName(v)) + output_matrix = Default(output_matrix, MBT_DefaultMatrix(w, h)) + return v.MBT_LinearResize(w, h, crop_l, crop_t, crop_w, crop_h, linear=linear, input_matrix=input_matrix, output_matrix=output_matrix) +} + +function MBT_LinearResize(clip src, int tw, int th, +\ float "src_left", float "src_top", float "src_width", float "src_height", +\ string "kernel", float "b", float "c", int "taps", float "p", int "bitdepth", +\ string "hkernel", string "vkernel", bool "linear", string "input_matrix", string "output_matrix") +{ + uscale = "Spline36" + dscale = "CatmullRom" + + hkernel = Default(hkernel , Default(kernel , (tw > src.Width ? uscale : dscale))) + vkernel = Default(vkernel , Default(kernel , (th > src.Height ? uscale : dscale))) + sl = Default(src_left , 0) + st = Default(src_top , 0) + sw = Default(src_width , src.Width) + sh = Default(src_height , src.Height) + b = Default(b , 1.0 / 3.0) + c = Default(c , 1.0 / 3.0) + p = Default(p , 30.0) + bitdepth = Default(bitdepth , src.BitsPerComponent) + linear = Defined(linear) ? linear : (sw / Float(tw) >= 1.5 || sh / Float(th) >= 1.5) + input_matrix = Default(input_matrix, src.IsRGB ? MBT_DefaultMatrix(src.Width, src.Height) : MBT_GetMatrixName(src)) + output_matrix = Default(output_matrix, MBT_DefaultMatrix(tw, th)) + Assert( +\ !linear || (FunctionExists("y_gamma_to_linear") && FunctionExists("y_linear_to_gamma")), +\ "Resize(linear=true) requires y_gamma_to_linear and y_linear_to_gamma. Load the missing dependency or call Resize(..., linear=false)." + \) + + clp = (src.IsRGB ? (src.HasAlpha ? src.ConvertToPlanarRGBA + \ : src.ConvertToPlanarRGB) + \ : src.ConvertToPlanarRGB(matrix=input_matrix)) + clp = clp.ConvertBits(32) + clp = linear ? clp.y_gamma_to_linear : clp + + if (hkernel != vkernel) { + htaps = Default(taps, (hkernel == "Lanczos" ? 3 : 4)) + vtaps = Default(taps, (vkernel == "Lanczos" ? 3 : 4)) + clp = clp.MBT_LinearResize(tw, src.Height, sl, 0, sw, 0, hkernel, b, c, htaps, p, linear=false, input_matrix=input_matrix, output_matrix=input_matrix) + clp = clp.MBT_LinearResize(tw, th, 0, st, 0, sh, vkernel, b, c, vtaps, p, linear=false, input_matrix=input_matrix, output_matrix=output_matrix) + } + else { + taps = Default(taps, (hkernel == "Lanczos" ? 3 : 4)) + clp = + \ (hkernel == "Bicubic" ) ? clp.BicubicResize(tw, th, b=b, c=c, src_left=sl, src_top=st, src_width=sw, src_height=sh) + \ : (hkernel == "Bilinear" ) ? clp.BilinearResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh) + \ : (hkernel == "Blackman" ) ? clp.BlackmanResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, taps=taps) + \ : (hkernel == "Gauss" ) ? clp.GaussResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, p=p) + \ : (hkernel == "Lanczos" ) ? clp.LanczosResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, taps=taps) + \ : (hkernel == "Lanczos4" ) ? clp.Lanczos4Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh) + \ : (hkernel == "Point" ) ? clp.PointResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh) + \ : (hkernel == "Sinc" ) ? clp.SincResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, taps=taps) + \ : (hkernel == "Spline16" ) ? clp.Spline16Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh) + \ : (hkernel == "Spline36" ) ? clp.Spline36Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh) + \ : (hkernel == "Spline64" ) ? clp.Spline64Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh) + \ : (hkernel == "CatmullRom" ) ? clp.BicubicResize(tw, th, b=0.0, c=0.5, src_left=sl, src_top=st, src_width=sw, src_height=sh) + \ : Assert(false, "Invalid resize kernel.") + } + + clp = linear ? clp.y_linear_to_gamma : clp + clp = src.IsRGB ? (src.HasAlpha ? clp.ConvertToPlanarRGBA : clp.ConvertToPlanarRGB) + \ : (src.Is420 ? clp.ConvertToYUV420(matrix=output_matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix) + \ : (src.Is422 ? clp.ConvertToYUV422(matrix=output_matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix) + \ : (src.Is444 ? clp.ConvertToYUV444(matrix=output_matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix) : clp.ConvertBits(bitdepth).MBT_SetMatrix(output_matrix)))) + clp = src.IsRGB ? clp.ConvertBits(bitdepth) : clp + + return clp +} + +function MBT_DefaultMatrix(int w, int h) +{ + return (w >= 3840 || h >= 2160) ? "Rec2020" : ((w >= 1280 || h >= 720) ? "Rec709" : "Rec601") +} + +function MBT_MatrixNameFromCode(int matrix) +{ + return (matrix == 1) ? "Rec709" + \ : (matrix == 9) ? "Rec2020" + \ : (matrix == 10) ? "Rec2020" + \ : (matrix == 5) ? "Rec601" + \ : (matrix == 6) ? "Rec601" + \ : "" +} + +function MBT_MatrixCodeForConvert(string matrix) +{ + return (matrix == "Rec601") ? "601" + \ : (matrix == "Rec709") ? "709" + \ : (matrix == "Rec2020") ? "2020" + \ : matrix +} + +function MBT_MatrixInt(string matrix) +{ + return (matrix == "Rec709") ? 1 + \ : (matrix == "Rec2020") ? 9 + \ : (matrix == "Rec601") ? 6 + \ : 2 +} + +function MBT_GetMatrixCode(clip c) +{ + matrix = MBT_MatrixNameFromCode(c.PropGetInt("_Matrix")) + return (matrix == "") ? MBT_MatrixInt(MBT_DefaultMatrix(c.Width, c.Height)) : c.PropGetInt("_Matrix") +} + +function MBT_GetMatrixName(clip c) +{ + matrix = MBT_MatrixNameFromCode(MBT_GetMatrixCode(c)) + return (matrix == "") ? MBT_DefaultMatrix(c.Width, c.Height) : matrix +} + +function MBT_SetMatrix(clip c, string matrix) +{ + return c.PropSet("_Matrix", MBT_MatrixInt(matrix)) +} + +function MBT_CorrectMatrix(clip c, string "input_matrix", string "output_matrix") +{ + input_matrix = Default(input_matrix, MBT_GetMatrixName(c)) + output_matrix = Default(output_matrix, MBT_DefaultMatrix(c.Width, c.Height)) + matrix = MBT_MatrixCodeForConvert(input_matrix) + ":auto=>" + MBT_MatrixCodeForConvert(output_matrix) + ":same" + bitdepth = c.BitsPerComponent + return c.IsRGB ? c + \ : (c.Is420 ? c.ConvertToYUV420(matrix=matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix) + \ : (c.Is422 ? c.ConvertToYUV422(matrix=matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix) + \ : (c.Is444 ? c.ConvertToYUV444(matrix=matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix) : c.MBT_SetMatrix(output_matrix)))) +} + +function DeShake(clip c, int "w", int "h", float "dar", float "crop", float "xcrop", float "ycrop", int "pos", bool "zoom", bool "rot", float "freq") +{ + # Stabilize through MVTools + DePan functions. Those plugins must be loaded + # in Avisynth before this function is called. + crop = Default(crop, 2.5) + xcrop = Default(xcrop, crop) + ycrop = Default(ycrop, crop) + zoom = Default(zoom, true) + rot = Default(rot, true) + freq = Default(freq, 1.0) + + vectors = c.MSuper().MAnalyse(isb=false, dct=5) + mdata = MDepan(c, vectors, thscd1=1000, thscd2=1000, zoom=zoom, rot=rot, error=255) + stabilized = DePanStabilize( + \ c, + \ data=mdata, + \ dxmax=c.Width * xcrop / 100.0, + \ dymax=c.Height * ycrop / 100.0, + \ rotmax=3.0, + \ mirror=15, + \ cutoff=freq + \) + return (Defined(w) || Defined(h) || Defined(dar)) ? Resize(stabilized, w, h, dar, xcrop, ycrop, pos) : stabilized +} + +function FixContrast(clip c, int "low", int "high") +{ + # Map input luma range to limited-range video luma. + low = Default(low, 16) + high = Default(high, 235) + return c.Levels(low, 1.0, high, 16, 235, coring=false) +} diff --git a/tools/avisynth_render.py b/tools/avisynth_render.py new file mode 100644 index 0000000..08cb877 --- /dev/null +++ b/tools/avisynth_render.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from tools.console import ProgressView +from tools.avisynth_validate import ( + FrameIdentity, + IdentityValidationResult, + read_frame_identity_csv, + validate_frame_identities, +) + + +@dataclass(frozen=True) +class AvisynthClipInfo: + width: int + height: int + chroma: str + bit_depth: int + frames: int + fps_num: int + fps_den: int + has_audio: bool + audio_rate: int + audio_channels: int + audio_samples: int + + @property + def duration_seconds(self) -> float | None: + if self.fps_num <= 0: + return None + return self.frames * self.fps_den / self.fps_num + + +@dataclass(frozen=True) +class AvisynthValidationRun: + script: Path + csv_path: Path + frames: list[FrameIdentity] + result: IdentityValidationResult + renderer_stdout: str + renderer_stderr: str + renderer_returncode: int + recovered_from_renderer_error: bool + clip_info: AvisynthClipInfo | None + + +_progress_view: ProgressView | None = None + + +def run_validation_script( + *, + renderer_command: list[str], + script: Path, + csv_path: Path, + expected_source_ids: set[int], + progress_label: str = "Validating", +) -> AvisynthValidationRun: + global _progress_view + if csv_path.exists(): + csv_path.unlink() + log_path = _runner_log_path(csv_path) + if log_path.exists(): + log_path.unlink() + + process = subprocess.Popen( + [*renderer_command, str(script), str(csv_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + assert process.stderr is not None + _progress_view = None + stderr_lines: list[str] = [] + for line in process.stderr: + if line.startswith("mbt_progress,"): + _print_progress(line, progress_label) + else: + stderr_lines.append(line) + stdout, _ = process.communicate() + _clear_progress_line(keep=True) + completed = subprocess.CompletedProcess( + process.args, + process.returncode, + stdout, + "".join(stderr_lines), + ) + if not csv_path.exists(): + raise RuntimeError( + f"Validation renderer did not create CSV: {csv_path}" + f"{_renderer_failure_suffix(completed, csv_path)}" + ) + + frames = read_frame_identity_csv(csv_path) + clip_info = parse_clip_info(completed.stdout) + recovered = completed.returncode != 0 + if recovered and not _has_complete_validation_output( + frames=frames, + clip_info=clip_info, + rendered_count=parse_rendered_count(completed.stdout), + ): + raise RuntimeError( + "Validation renderer failed before producing a complete frame table" + f"{_renderer_failure_suffix(completed, csv_path)}" + ) + + result = validate_frame_identities(frames, expected_source_ids=expected_source_ids) + return AvisynthValidationRun( + script=script, + csv_path=csv_path, + frames=frames, + result=result, + renderer_stdout=completed.stdout, + renderer_stderr=completed.stderr, + renderer_returncode=completed.returncode, + recovered_from_renderer_error=recovered, + clip_info=clip_info, + ) + + +def parse_clip_info(stdout: str) -> AvisynthClipInfo | None: + for line in stdout.splitlines(): + if not line.startswith("clip_info,"): + continue + values: dict[str, str] = {} + for part in line.split(",")[1:]: + if "=" not in part: + continue + key, value = part.split("=", 1) + values[key] = value + try: + return AvisynthClipInfo( + width=int(values["width"]), + height=int(values["height"]), + chroma=values.get("chroma", "unknown"), + bit_depth=int(values.get("bit_depth", "8")), + frames=int(values["frames"]), + fps_num=int(values["fps_num"]), + fps_den=int(values["fps_den"]), + has_audio=bool(int(values["has_audio"])), + audio_rate=int(values["audio_rate"]), + audio_channels=int(values.get("audio_channels", "0")), + audio_samples=int(values["audio_samples"]), + ) + except (KeyError, ValueError) as exc: + raise RuntimeError(f"Invalid Avisynth clip_info line: {line}") from exc + return None + + +def parse_rendered_count(stdout: str) -> int | None: + for line in stdout.splitlines(): + if not line.startswith("rendered="): + continue + try: + return int(line.split("=", 1)[1]) + except ValueError as exc: + raise RuntimeError(f"Invalid Avisynth rendered line: {line}") from exc + return None + + +def _has_complete_validation_output( + *, + frames: list[FrameIdentity], + clip_info: AvisynthClipInfo | None, + rendered_count: int | None, +) -> bool: + if rendered_count is not None: + return len(frames) == rendered_count + if clip_info is not None: + return len(frames) == clip_info.frames + return False + + +def _renderer_failure_suffix( + completed: subprocess.CompletedProcess[str], + csv_path: Path, +) -> str: + parts = [f" (exit code {completed.returncode})"] + if completed.stderr.strip(): + parts.append(f"\nstderr:\n{completed.stderr.strip()}") + if completed.stdout.strip(): + parts.append(f"\nstdout:\n{completed.stdout.strip()}") + log_path = _runner_log_path(csv_path) + if log_path.exists(): + parts.append(f"\nrunner log ({log_path}):\n{log_path.read_text(encoding='utf-8', errors='replace').strip()}") + return "".join(parts) + + +def _runner_log_path(csv_path: Path) -> Path: + return Path(f"{csv_path}.runner.log") + + +def _print_progress(line: str, label: str) -> None: + global _progress_view + if not sys.stdout.isatty(): + return + parts = line.strip().split(",") + if len(parts) != 6: + return + try: + current = int(parts[1]) + total = int(parts[2]) + float(parts[3]) + elapsed = float(parts[4]) + eta = float(parts[5]) + except ValueError: + return + if _progress_view is None or _progress_view.total != total: + _progress_view = ProgressView( + total, + label, + embedded_percent=True, + show_rate=True, + ) + _progress_view.update_external( + current, + elapsed=elapsed, + eta=eta if current >= 2 else None, + ) + + +def _clear_progress_line(*, keep: bool) -> None: + global _progress_view + if not sys.stdout.isatty(): + return + if _progress_view is not None: + _progress_view.finish(keep=keep) + _progress_view = None diff --git a/tools/avisynth_runner.py b/tools/avisynth_runner.py new file mode 100644 index 0000000..72ef26a --- /dev/null +++ b/tools/avisynth_runner.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class AvisynthRunnerSet: + default: Path + runner64: Path | None = None + runner32: Path | None = None + + def for_script(self, script: Path) -> Path: + if sys.platform != "win32": + return self.default + if script_requests_32bit(script): + if self.runner32 is None: + raise RuntimeError( + f"{script} requests 32-bit AviSynth with a trailing #32bit " + "marker, but no 32-bit runner was found. Set " + "MBT_AVS_RUNNER32 or provide tools/avisynth_runner/" + "mbt_avs_runner32.exe." + ) + return self.runner32 + return self.default + + +def bundled_runner_path(name: str | None = None) -> Path | None: + if name is None: + name = "mbt_avs_runner.exe" if sys.platform == "win32" else "mbt_avs_runner" + runner = _runner_dir() / name + return runner if runner.is_file() else None + + +def runner_path_from_env_or_bundled() -> Path | None: + runners = runner_set_from_env_or_bundled() + return runners.default if runners is not None else None + + +def runner_set_from_env_or_bundled() -> AvisynthRunnerSet | None: + if sys.platform == "win32": + return _windows_runner_set_from_env_or_bundled() + + env_path = os.environ.get("MBT_AVS_RUNNER", "").strip() + if env_path: + runner = validate_runner_path(env_path) + return AvisynthRunnerSet(default=runner, runner64=runner) + bundled = bundled_runner_path() + if bundled is not None: + return AvisynthRunnerSet(default=bundled, runner64=bundled) + return None + + +def _windows_runner_set_from_env_or_bundled() -> AvisynthRunnerSet | None: + runner64 = _optional_runner_from_env("MBT_AVS_RUNNER64") + if runner64 is None: + runner64 = _optional_runner_from_env("MBT_AVS_RUNNER") + if runner64 is None: + runner64 = bundled_runner_path("mbt_avs_runner.exe") + + runner32 = _optional_runner_from_env("MBT_AVS_RUNNER32") + if runner32 is None: + runner32 = bundled_runner_path("mbt_avs_runner32.exe") + + default = runner64 or runner32 + if default is None: + return None + return AvisynthRunnerSet(default=default, runner64=runner64, runner32=runner32) + + +def _optional_runner_from_env(name: str) -> Path | None: + raw = os.environ.get(name, "").strip() + if not raw: + return None + return validate_runner_path(raw) + + +def validate_runner_path(raw_path: str) -> Path: + runner_path = Path(raw_path.strip().strip('"')).expanduser() + for candidate in _runner_candidates(runner_path): + if candidate.is_file(): + return candidate.resolve() + raise RuntimeError( + f"Avisynth runner file not found: {runner_path}. " + "Use the full path, set MBT_AVS_RUNNER, or leave blank to use the " + "bundled runner when present." + ) + + +def _runner_candidates(path: Path) -> list[Path]: + candidates = [path] + if len(path.parts) == 1: + candidates.append(_runner_dir() / path.name) + return candidates + + +def _runner_dir() -> Path: + return Path(__file__).resolve().parent / "avisynth_runner" + + +def script_requests_32bit(script: Path) -> bool: + lines = script.read_text(encoding="utf-8").splitlines() + while lines and not lines[-1].strip(): + lines.pop() + if not lines: + return False + normalized = "".join(lines[-1].split()).lower() + return normalized in {"#32bit", "#32-bit"} diff --git a/tools/avisynth_runner/build_linux.sh b/tools/avisynth_runner/build_linux.sh new file mode 100755 index 0000000..8ed30f7 --- /dev/null +++ b/tools/avisynth_runner/build_linux.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +cc="${CC:-cc}" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +output="$script_dir/mbt_avs_runner" + +pkg_cflags="$(pkg-config --cflags avisynth 2>/dev/null || true)" +pkg_libs="$(pkg-config --libs avisynth 2>/dev/null || true)" + +if [[ -z "$pkg_cflags$pkg_libs" && -z "${CFLAGS:-}${LDFLAGS:-}" ]]; then + echo "Could not find avisynth via pkg-config." >&2 + echo "Set CFLAGS and LDFLAGS manually, or install AviSynth+ development files." >&2 + exit 1 +fi + +"$cc" ${CFLAGS:-} $pkg_cflags -O2 -Wall -Wextra \ + -o "$output" "$script_dir/mbt_avs_runner.c" \ + ${LDFLAGS:-} $pkg_libs + +echo "built $output" diff --git a/tools/avisynth_runner/build_windows.sh b/tools/avisynth_runner/build_windows.sh new file mode 100755 index 0000000..b65e5dd --- /dev/null +++ b/tools/avisynth_runner/build_windows.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +include_dir="${AVISYNTH_INCLUDE_DIR:-}" + +if [[ -z "$include_dir" ]]; then + for candidate in \ + /tmp/media_batch_avisynth/avisynthplus/avs_core/include \ + /tmp/media_batch_avisynth/install/include/avisynth + do + if [[ -f "$candidate/avisynth_c.h" ]]; then + include_dir="$candidate" + break + fi + done +fi + +if [[ -z "$include_dir" || ! -f "$include_dir/avisynth_c.h" ]]; then + echo "Could not find avisynth_c.h." >&2 + echo "Set AVISYNTH_INCLUDE_DIR to an AviSynth+ include directory." >&2 + exit 1 +fi + +build_one() { + local cc="$1" + local output="$2" + if ! command -v "$cc" >/dev/null 2>&1; then + echo "Skipping $output; $cc not found." >&2 + return 0 + fi + "$cc" -I"$include_dir" -O2 -Wall -Wextra \ + -o "$script_dir/$output" "$script_dir/mbt_avs_runner.c" + echo "built $script_dir/$output" +} + +build_one "${WIN64_CC:-x86_64-w64-mingw32-gcc}" mbt_avs_runner.exe +build_one "${WIN32_CC:-i686-w64-mingw32-gcc}" mbt_avs_runner32.exe diff --git a/tools/avisynth_runner/mbt_avs_runner.c b/tools/avisynth_runner/mbt_avs_runner.c new file mode 100644 index 0000000..aac54ab --- /dev/null +++ b/tools/avisynth_runner/mbt_avs_runner.c @@ -0,0 +1,853 @@ +#ifdef _WIN32 +#define AVSC_NO_DECLSPEC +#include +#include +#include +#endif + +#include + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +static AVS_Library* avs_library = NULL; + +#define avs_clip_get_error avs_library->avs_clip_get_error +#define avs_create_script_environment avs_library->avs_create_script_environment +#define avs_delete_script_environment avs_library->avs_delete_script_environment +#define avs_get_audio avs_library->avs_get_audio +#define avs_get_frame avs_library->avs_get_frame +#define avs_get_frame_props_ro avs_library->avs_get_frame_props_ro +#define avs_get_height_p avs_library->avs_get_height_p +#define avs_get_pitch_p avs_library->avs_get_pitch_p +#define avs_get_read_ptr_p avs_library->avs_get_read_ptr_p +#define avs_get_row_size_p avs_library->avs_get_row_size_p +#define avs_get_video_info avs_library->avs_get_video_info +#define avs_invoke avs_library->avs_invoke +#define avs_bits_per_component avs_library->avs_bits_per_component +#define avs_is_420 avs_library->avs_is_420 +#define avs_is_422 avs_library->avs_is_422 +#define avs_is_444 avs_library->avs_is_444 +#define avs_is_yv12 avs_library->avs_is_yv12 +#define avs_is_yv16 avs_library->avs_is_yv16 +#define avs_is_yv24 avs_library->avs_is_yv24 +#define avs_prop_get_int avs_library->avs_prop_get_int +#define avs_release_clip avs_library->avs_release_clip +#define avs_release_value avs_library->avs_release_value +#define avs_release_video_frame avs_library->avs_release_video_frame +#define avs_set_var avs_library->avs_set_var +#define avs_take_clip avs_library->avs_take_clip +#endif + +enum RunnerMode { + MODE_VALIDATE, + MODE_VALIDATE_REAL, + MODE_Y4M, + MODE_Y4M_SKIP_DROP, + MODE_WAV, + MODE_CHECK_SOURCE, +}; + +#define MBT_AVISYNTH_ENV_VERSION 8 + +static void usage(void) { + fprintf(stderr, "usage: mbt_avs_runner --validate script.avs [frames.csv]\n"); + fprintf(stderr, " mbt_avs_runner --validate-real script.avs [frames.csv]\n"); + fprintf(stderr, " mbt_avs_runner --check-source expected_source_id script.avs\n"); + fprintf(stderr, " mbt_avs_runner --y4m script.avs\n"); + fprintf(stderr, " mbt_avs_runner --y4m-skip-drop fps_num fps_den script.avs\n"); + fprintf(stderr, " mbt_avs_runner --wav script.avs\n"); +} + +static int is_validation_mode(enum RunnerMode mode) { + return mode == MODE_VALIDATE || mode == MODE_VALIDATE_REAL; +} + +static int is_y4m_mode(enum RunnerMode mode) { + return mode == MODE_Y4M || mode == MODE_Y4M_SKIP_DROP; +} + +static int configure_binary_stdout(enum RunnerMode mode) { +#ifdef _WIN32 + if ( + (mode == MODE_Y4M || mode == MODE_Y4M_SKIP_DROP || mode == MODE_WAV) + && _setmode(_fileno(stdout), _O_BINARY) == -1 + ) { + fprintf(stderr, "failed to set binary stdout mode\n"); + return 0; + } +#else + (void)mode; +#endif + return 1; +} + +static int set_validation_blank_source( + AVS_ScriptEnvironment* env, + enum RunnerMode mode +) { + if (!is_validation_mode(mode)) { + return 1; + } + AVS_Value value = avs_new_value_bool(mode == MODE_VALIDATE); + if (!avs_set_var(env, "mbt_validation_blank", value)) { + fprintf(stderr, "failed to set validation source mode\n"); + return 0; + } + return 1; +} + +static double now_seconds(void) { +#ifdef _WIN32 + static LARGE_INTEGER frequency; + LARGE_INTEGER counter; + if (frequency.QuadPart == 0) { + QueryPerformanceFrequency(&frequency); + } + QueryPerformanceCounter(&counter); + return (double)counter.QuadPart / (double)frequency.QuadPart; +#else + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1000000000.0; +#endif +} + +static void write_progress( + const char* kind, + int processed, + int total, + double elapsed, + double eta +) { + double percent = total > 0 ? (100.0 * (double)processed / (double)total) : 100.0; + fprintf( + stderr, + "%s,%d,%d,%.2f,%.3f,%.3f\n", + kind, + processed, + total, + percent, + elapsed, + eta + ); + fflush(stderr); +} + +static FILE* open_validation_log(const char* validation_csv_path) { + if (!validation_csv_path) { + return 0; + } + size_t length = strlen(validation_csv_path) + strlen(".runner.log") + 1; + char* path = (char*)malloc(length); + if (!path) { + return 0; + } + snprintf(path, length, "%s.runner.log", validation_csv_path); + FILE* log = fopen(path, "wb"); + free(path); + return log; +} + +static void log_checkpoint(FILE* log, const char* message) { + if (!log) { + return; + } + fputs(message, log); + fputc('\n', log); + fflush(log); +} + +static void log_checkpoint_int(FILE* log, const char* message, int value) { + if (!log) { + return; + } + fprintf(log, "%s%d\n", message, value); + fflush(log); +} + +static void log_runner_arch(FILE* log) { +#if defined(_WIN64) + log_checkpoint(log, "runner: windows 64-bit"); +#elif defined(_WIN32) + log_checkpoint(log, "runner: windows 32-bit"); +#else + log_checkpoint(log, "runner: non-windows"); +#endif +} + +static AVS_Value initialized_string_value(const char* value) { + AVS_Value avs_value = avs_void; + avs_value.type = 's'; + avs_value.array_size = 0; + avs_value.d.string = value; + return avs_value; +} + +#ifdef _WIN32 +static void log_loaded_dll_path(FILE* log, const char* dll_name) { + if (!log) { + return; + } + HMODULE module = GetModuleHandleA(dll_name); + if (!module) { + fprintf(log, "dll: %s loaded, path unavailable\n", dll_name); + fflush(log); + return; + } + + char path[MAX_PATH]; + DWORD length = GetModuleFileNameA(module, path, sizeof(path)); + if (length == 0 || length >= sizeof(path)) { + fprintf(log, "dll: %s loaded, path unavailable\n", dll_name); + fflush(log); + return; + } + fprintf(log, "dll: %s\n", path); + fflush(log); +} +#endif + +static int require_frame_property_api(FILE* log) { +#ifdef _WIN32 + if ( + avs_get_frame_props_ro + && avs_prop_get_int + ) { + return 1; + } + log_checkpoint(log, "failed: frame property API unavailable"); + fprintf(stderr, "loaded AviSynth does not expose the frame property C API required by media-batch-tools\n"); + return 0; +#else + (void)log; + return 1; +#endif +} + +static void write_plane(const AVS_VideoFrame* frame, int plane) { + const unsigned char* ptr = avs_get_read_ptr_p(frame, plane); + int pitch = avs_get_pitch_p(frame, plane); + int row_size = avs_get_row_size_p(frame, plane); + int height = avs_get_height_p(frame, plane); + for (int y = 0; y < height; y++) { + fwrite(ptr + y * pitch, 1, row_size, stdout); + } +} + +static int video_bit_depth(const AVS_VideoInfo* vi) { +#ifdef _WIN32 + if (!avs_bits_per_component) { + return 8; + } +#endif + int depth = avs_bits_per_component(vi); + return depth > 0 ? depth : 8; +} + +static const char* y4m_chroma_tag(int family, int bit_depth) { + if (family == 420) { + if (bit_depth <= 8) return "C420jpeg"; + if (bit_depth == 10) return "C420p10"; + if (bit_depth == 12) return "C420p12"; + if (bit_depth == 14) return "C420p14"; + if (bit_depth == 16) return "C420p16"; + } + if (family == 422) { + if (bit_depth <= 8) return "C422"; + if (bit_depth == 10) return "C422p10"; + if (bit_depth == 12) return "C422p12"; + if (bit_depth == 14) return "C422p14"; + if (bit_depth == 16) return "C422p16"; + } + if (family == 444) { + if (bit_depth <= 8) return "C444"; + if (bit_depth == 10) return "C444p10"; + if (bit_depth == 12) return "C444p12"; + if (bit_depth == 14) return "C444p14"; + if (bit_depth == 16) return "C444p16"; + } + return 0; +} + +static const char* chroma_tag(const AVS_VideoInfo* vi) { + int depth = video_bit_depth(vi); +#ifdef _WIN32 + if (avs_is_420 && avs_is_420(vi)) return y4m_chroma_tag(420, depth); + if (avs_is_422 && avs_is_422(vi)) return y4m_chroma_tag(422, depth); + if (avs_is_444 && avs_is_444(vi)) return y4m_chroma_tag(444, depth); +#else + if (avs_is_420(vi)) return y4m_chroma_tag(420, depth); + if (avs_is_422(vi)) return y4m_chroma_tag(422, depth); + if (avs_is_444(vi)) return y4m_chroma_tag(444, depth); +#endif + if (avs_is_yv12(vi)) return y4m_chroma_tag(420, 8); + if (avs_is_yv16(vi)) return y4m_chroma_tag(422, 8); + if (avs_is_yv24(vi)) return y4m_chroma_tag(444, 8); + return 0; +} + +static void write_le16(uint16_t value) { + unsigned char bytes[2]; + bytes[0] = (unsigned char)(value & 0xff); + bytes[1] = (unsigned char)((value >> 8) & 0xff); + fwrite(bytes, 1, 2, stdout); +} + +static void write_le32(uint32_t value) { + unsigned char bytes[4]; + bytes[0] = (unsigned char)(value & 0xff); + bytes[1] = (unsigned char)((value >> 8) & 0xff); + bytes[2] = (unsigned char)((value >> 16) & 0xff); + bytes[3] = (unsigned char)((value >> 24) & 0xff); + fwrite(bytes, 1, 4, stdout); +} + +static int wav_format_tag(const AVS_VideoInfo* vi) { + return avs_sample_type(vi) == AVS_SAMPLE_FLOAT ? 3 : 1; +} + +static int wav_bits_per_sample(const AVS_VideoInfo* vi) { + return avs_bytes_per_channel_sample(vi) * 8; +} + +static int read_int_frame_prop( + AVS_ScriptEnvironment* env, + const AVS_VideoFrame* frame, + const char* name, + int64_t* value_out +); + +static int write_wav(AVS_Clip* clip, const AVS_VideoInfo* vi) { + if (!avs_has_audio(vi)) { + fprintf(stderr, "script has no audio\n"); + return 6; + } + if ( + avs_sample_type(vi) != AVS_SAMPLE_INT16 + && avs_sample_type(vi) != AVS_SAMPLE_INT24 + && avs_sample_type(vi) != AVS_SAMPLE_INT32 + && avs_sample_type(vi) != AVS_SAMPLE_FLOAT + ) { + fprintf(stderr, "unsupported Avisynth audio sample type for WAV output: %d\n", avs_sample_type(vi)); + return 7; + } + + int channels = avs_audio_channels(vi); + int sample_rate = avs_samples_per_second(vi); + int bytes_per_sample = avs_bytes_per_audio_sample(vi); + int bits_per_sample = wav_bits_per_sample(vi); + int64_t total_samples = vi->num_audio_samples; + int64_t data_size_64 = total_samples * bytes_per_sample; + if (data_size_64 > 0xffffffffLL - 36) { + fprintf(stderr, "WAV output would exceed RIFF size limit\n"); + return 8; + } + + uint32_t data_size = (uint32_t)data_size_64; + uint32_t byte_rate = (uint32_t)(sample_rate * bytes_per_sample); + uint16_t block_align = (uint16_t)bytes_per_sample; + + fwrite("RIFF", 1, 4, stdout); + write_le32(36 + data_size); + fwrite("WAVE", 1, 4, stdout); + fwrite("fmt ", 1, 4, stdout); + write_le32(16); + write_le16((uint16_t)wav_format_tag(vi)); + write_le16((uint16_t)channels); + write_le32((uint32_t)sample_rate); + write_le32(byte_rate); + write_le16(block_align); + write_le16((uint16_t)bits_per_sample); + fwrite("data", 1, 4, stdout); + write_le32(data_size); + + const int64_t chunk_samples = 65536; + unsigned char* buffer = (unsigned char*)malloc((size_t)(chunk_samples * bytes_per_sample)); + if (!buffer) { + fprintf(stderr, "failed to allocate audio buffer\n"); + return 10; + } + for (int64_t start = 0; start < total_samples; start += chunk_samples) { + int64_t count = total_samples - start; + if (count > chunk_samples) { + count = chunk_samples; + } + int byte_count = (int)(count * bytes_per_sample); + int error = avs_get_audio(clip, buffer, start, count); + if (error) { + const char* err = avs_clip_get_error(clip); + fprintf(stderr, "failed to get audio at sample %lld%s%s\n", (long long)start, err ? ": " : "", err ? err : ""); + free(buffer); + return 9; + } + fwrite(buffer, 1, byte_count, stdout); + } + free(buffer); + return 0; +} + +static int frame_is_marked_drop(AVS_ScriptEnvironment* env, const AVS_VideoFrame* frame) { + int64_t value = 0; + return read_int_frame_prop(env, frame, "mbt_drop_frame", &value) == 1 && value != 0; +} + +static int check_source_id( + AVS_ScriptEnvironment* env, + AVS_Clip* clip, + const AVS_VideoInfo* vi, + int64_t expected_source_id +) { + if (vi->num_frames <= 0) { + fprintf(stderr, "script has no video frames\n"); + return 13; + } + + AVS_VideoFrame* frame = avs_get_frame(clip, 0); + if (!frame) { + const char* err = avs_clip_get_error(clip); + fprintf(stderr, "failed to get frame 0%s%s\n", err ? ": " : "", err ? err : ""); + return 5; + } + + int64_t source_id = 0; + int status = read_int_frame_prop(env, frame, "mbt_source_id", &source_id); + avs_release_video_frame(frame); + if (status < 0) { + fprintf(stderr, "failed to read mbt_source_id at frame 0\n"); + return 12; + } + if (status == 0) { + fprintf(stderr, "frame 0 is missing mbt_source_id\n"); + return 14; + } + if (source_id != expected_source_id) { + fprintf( + stderr, + "frame 0 has mbt_source_id %lld, expected %lld\n", + (long long)source_id, + (long long)expected_source_id + ); + return 15; + } + + printf("source_id=%lld\n", (long long)source_id); + fflush(stdout); + return 0; +} + +static int read_int_frame_prop( + AVS_ScriptEnvironment* env, + const AVS_VideoFrame* frame, + const char* name, + int64_t* value_out +) { + const AVS_Map* props = avs_get_frame_props_ro(env, frame); + if (!props) { + return 0; + } + + int error = AVS_GETPROPERROR_SUCCESS; + int64_t value = avs_prop_get_int(env, props, name, 0, &error); + if (error == AVS_GETPROPERROR_UNSET || error == AVS_GETPROPERROR_INDEX) { + return 0; + } + if (error != AVS_GETPROPERROR_SUCCESS) { + return -1; + } + *value_out = value; + return 1; +} + +static int write_validation_row( + FILE* csv, + AVS_ScriptEnvironment* env, + const AVS_VideoFrame* frame, + int output_frame +) { + int64_t source_id = 0; + int64_t source_frame = 0; + int64_t drop_frame = 0; + int64_t matrix = 0; + int source_id_status = read_int_frame_prop(env, frame, "mbt_source_id", &source_id); + int source_frame_status = read_int_frame_prop(env, frame, "mbt_source_frame", &source_frame); + int drop_frame_status = read_int_frame_prop(env, frame, "mbt_drop_frame", &drop_frame); + int matrix_status = read_int_frame_prop(env, frame, "_Matrix", &matrix); + if (source_id_status < 0 || source_frame_status < 0 || drop_frame_status < 0 || matrix_status < 0) { + fprintf(stderr, "failed to read media-batch-tools frame properties at frame %d\n", output_frame); + return 12; + } + + fprintf(csv, "%d,", output_frame); + if (source_id_status > 0) { + fprintf(csv, "%lld", (long long)source_id); + } + fputc(',', csv); + if (source_frame_status > 0) { + fprintf(csv, "%lld", (long long)source_frame); + } + fprintf(csv, ",%lld,", drop_frame_status > 0 ? (long long)drop_frame : 0LL); + if (matrix_status > 0) { + fprintf(csv, "%lld", (long long)matrix); + } + fputc('\n', csv); + return 0; +} + +static int render_clip( + AVS_ScriptEnvironment* env, + AVS_Clip* clip, + const AVS_VideoInfo* vi, + enum RunnerMode mode, + unsigned int y4m_fps_num, + unsigned int y4m_fps_den, + const char* validation_csv_path, + FILE* diagnostic_log +) { + if (mode == MODE_WAV) { + return write_wav(clip, vi); + } + + const char* chroma = 0; + FILE* validation_csv = 0; + double* progress_times = 0; + double progress_start = 0.0; + double last_progress = 0.0; + int report_progress = is_validation_mode(mode) || is_y4m_mode(mode); + const char* progress_kind = is_validation_mode(mode) + ? "mbt_progress" + : "mbt_render"; + if (is_validation_mode(mode)) { + const char* clip_chroma = chroma_tag(vi); + log_checkpoint(diagnostic_log, "render: clip_info"); + printf( + "clip_info,width=%d,height=%d,chroma=%s,bit_depth=%d,frames=%d,fps_num=%u,fps_den=%u,has_audio=%d,audio_rate=%d,audio_channels=%d,audio_samples=%lld\n", + vi->width, + vi->height, + clip_chroma ? clip_chroma : "unknown", + video_bit_depth(vi), + vi->num_frames, + vi->fps_numerator, + vi->fps_denominator, + avs_has_audio(vi) ? 1 : 0, + vi->audio_samples_per_second, + vi->nchannels, + (long long)vi->num_audio_samples + ); + fflush(stdout); + if (validation_csv_path) { + log_checkpoint(diagnostic_log, "render: open validation csv"); + validation_csv = fopen(validation_csv_path, "wb"); + if (!validation_csv) { + fprintf(stderr, "failed to open validation CSV for writing: %s\n", validation_csv_path); + return 11; + } + fputs("output_frame,source_id,source_frame,drop_frame,matrix\n", validation_csv); + fflush(validation_csv); + } + } + if (report_progress) { + progress_times = (double*)calloc((size_t)vi->num_frames + 1, sizeof(double)); + progress_start = now_seconds(); + last_progress = progress_start; + if (progress_times) { + progress_times[0] = progress_start; + } + write_progress(progress_kind, 0, vi->num_frames, 0.0, 0.0); + } + + if (mode == MODE_Y4M || mode == MODE_Y4M_SKIP_DROP) { + chroma = chroma_tag(vi); + if (!chroma) { + fprintf(stderr, "unsupported Avisynth pixel format for Y4M output: %d\n", vi->pixel_type); + free(progress_times); + return 4; + } + fprintf( + stdout, + "YUV4MPEG2 W%d H%d F%u:%u Ip A0:0 %s\n", + vi->width, + vi->height, + y4m_fps_num, + y4m_fps_den, + chroma + ); + fflush(stdout); + } + + for (int n = 0; n < vi->num_frames; n++) { + if (is_validation_mode(mode) && (n == 0 || n == vi->num_frames - 1 || n % 1000 == 0)) { + log_checkpoint_int(diagnostic_log, "render: frame ", n); + } + AVS_VideoFrame* frame = avs_get_frame(clip, n); + if (!frame) { + const char* err = avs_clip_get_error(clip); + fprintf(stderr, "failed to get frame %d%s%s\n", n, err ? ": " : "", err ? err : ""); + return 5; + } + int skip_output = mode == MODE_Y4M_SKIP_DROP && frame_is_marked_drop(env, frame); + if (!skip_output && is_y4m_mode(mode)) { + fputs("FRAME\n", stdout); + write_plane(frame, AVS_PLANAR_Y); + write_plane(frame, AVS_PLANAR_U); + write_plane(frame, AVS_PLANAR_V); + } + if (is_validation_mode(mode) && validation_csv) { + int csv_status = write_validation_row(validation_csv, env, frame, n); + if (csv_status != 0) { + fclose(validation_csv); + free(progress_times); + avs_release_video_frame(frame); + return csv_status; + } + } + avs_release_video_frame(frame); + if (report_progress) { + int processed = n + 1; + double current_time = now_seconds(); + if (progress_times) { + progress_times[processed] = current_time; + } + if (processed == vi->num_frames || current_time - last_progress >= 0.5) { + int window = processed / 2; + int window_start = processed - window; + double window_elapsed = 0.0; + double eta = 0.0; + if (window > 0 && progress_times) { + window_elapsed = current_time - progress_times[window_start]; + eta = window_elapsed * (double)(vi->num_frames - processed) / (double)window; + } else if (processed > 0) { + window_elapsed = current_time - progress_start; + eta = window_elapsed * (double)(vi->num_frames - processed) / (double)processed; + } + write_progress( + progress_kind, + processed, + vi->num_frames, + current_time - progress_start, + eta + ); + last_progress = current_time; + } + } + } + + if (is_validation_mode(mode)) { + if (validation_csv) { + log_checkpoint(diagnostic_log, "render: close validation csv"); + fclose(validation_csv); + } + log_checkpoint(diagnostic_log, "render: complete"); + printf("rendered=%d\n", vi->num_frames); + fflush(stdout); + } + free(progress_times); + return 0; +} + +int main(int argc, char** argv) { + enum RunnerMode mode; + const char* script_path; + const char* validation_csv_path = 0; + FILE* diagnostic_log = 0; + unsigned int y4m_fps_num = 0; + unsigned int y4m_fps_den = 0; + int64_t expected_source_id = 0; + + if (argc != 3 && argc != 4 && argc != 5) { + usage(); + return 2; + } + if (strcmp(argv[1], "--validate") == 0) { + if (argc != 3 && argc != 4) { + usage(); + return 2; + } + mode = MODE_VALIDATE; + if (argc == 4) { + validation_csv_path = argv[3]; + } + } else if (strcmp(argv[1], "--validate-real") == 0) { + if (argc != 3 && argc != 4) { + usage(); + return 2; + } + mode = MODE_VALIDATE_REAL; + if (argc == 4) { + validation_csv_path = argv[3]; + } + } else if (strcmp(argv[1], "--check-source") == 0) { + if (argc != 4) { + usage(); + return 2; + } + mode = MODE_CHECK_SOURCE; + expected_source_id = (int64_t)strtoll(argv[2], 0, 10); + } else if (strcmp(argv[1], "--y4m") == 0) { + if (argc != 3) { + usage(); + return 2; + } + mode = MODE_Y4M; + } else if (strcmp(argv[1], "--y4m-skip-drop") == 0) { + if (argc != 5) { + usage(); + return 2; + } + mode = MODE_Y4M_SKIP_DROP; + y4m_fps_num = (unsigned int)strtoul(argv[2], 0, 10); + y4m_fps_den = (unsigned int)strtoul(argv[3], 0, 10); + if (y4m_fps_num == 0 || y4m_fps_den == 0) { + fprintf(stderr, "Y4M fps numerator and denominator must be positive\n"); + return 2; + } + } else if (strcmp(argv[1], "--wav") == 0) { + if (argc != 3) { + usage(); + return 2; + } + mode = MODE_WAV; + } else { + usage(); + return 2; + } + if (!configure_binary_stdout(mode)) { + return 2; + } + script_path = is_validation_mode(mode) ? argv[2] : argv[argc - 1]; + diagnostic_log = open_validation_log(validation_csv_path); + log_checkpoint(diagnostic_log, "start"); + log_runner_arch(diagnostic_log); + log_checkpoint(diagnostic_log, script_path); + +#ifdef _WIN32 + log_checkpoint(diagnostic_log, "load: AviSynth.dll"); + avs_library = avs_load_library(); + if (!avs_library) { + log_checkpoint(diagnostic_log, "failed: AviSynth.dll"); + fprintf(stderr, "failed to load AviSynth.dll. Install 64-bit AviSynth+ and make sure AviSynth.dll can be found.\n"); + if (diagnostic_log) { + fclose(diagnostic_log); + } + return 2; + } + log_loaded_dll_path(diagnostic_log, "AviSynth.dll"); +#endif + + log_checkpoint_int(diagnostic_log, "create: script environment v", MBT_AVISYNTH_ENV_VERSION); + AVS_ScriptEnvironment* env = avs_create_script_environment(MBT_AVISYNTH_ENV_VERSION); + if (!env) { + log_checkpoint(diagnostic_log, "failed: script environment"); + fprintf(stderr, "failed to create Avisynth environment\n"); +#ifdef _WIN32 + avs_free_library(avs_library); +#endif + if (diagnostic_log) { + fclose(diagnostic_log); + } + return 2; + } + if (!require_frame_property_api(diagnostic_log)) { + avs_delete_script_environment(env); +#ifdef _WIN32 + avs_free_library(avs_library); +#endif + if (diagnostic_log) { + fclose(diagnostic_log); + } + return 2; + } + + if (!set_validation_blank_source(env, mode)) { + avs_delete_script_environment(env); +#ifdef _WIN32 + avs_free_library(avs_library); +#endif + if (diagnostic_log) { + fclose(diagnostic_log); + } + return 2; + } + + log_checkpoint(diagnostic_log, "import: script"); + AVS_Value import_arg = initialized_string_value(script_path); + AVS_Value result = avs_invoke(env, "Import", import_arg, 0); + if (avs_is_error(result)) { + log_checkpoint(diagnostic_log, "failed: import"); + fprintf(stderr, "%s\n", avs_as_string(result)); + avs_release_value(result); + avs_delete_script_environment(env); +#ifdef _WIN32 + avs_free_library(avs_library); +#endif + if (diagnostic_log) { + fclose(diagnostic_log); + } + return 1; + } + if (!avs_is_clip(result)) { + log_checkpoint(diagnostic_log, "failed: script did not return clip"); + fprintf(stderr, "script did not return a clip\n"); + avs_release_value(result); + avs_delete_script_environment(env); +#ifdef _WIN32 + avs_free_library(avs_library); +#endif + if (diagnostic_log) { + fclose(diagnostic_log); + } + return 3; + } + + log_checkpoint(diagnostic_log, "take: clip"); + AVS_Clip* clip = avs_take_clip(result, env); + avs_release_value(result); + log_checkpoint(diagnostic_log, "get: video info"); + const AVS_VideoInfo* vi = avs_get_video_info(clip); + log_checkpoint_int(diagnostic_log, "info: frames ", vi->num_frames); + if (y4m_fps_num == 0) { + y4m_fps_num = vi->fps_numerator; + } + if (y4m_fps_den == 0) { + y4m_fps_den = vi->fps_denominator; + } + int status = mode == MODE_CHECK_SOURCE + ? check_source_id(env, clip, vi, expected_source_id) + : render_clip( + env, + clip, + vi, + mode, + y4m_fps_num, + y4m_fps_den, + validation_csv_path, + diagnostic_log + ); + +#ifdef _WIN32 + /* + * Some Windows AviSynth/plugin combinations can crash during final + * teardown after all frames have rendered. Let the OS reclaim process + * resources so validation/encoding results are not lost to cleanup. + */ + fflush(stdout); + fflush(stderr); + if (diagnostic_log) { + log_checkpoint_int(diagnostic_log, "exit: ", status); + fclose(diagnostic_log); + } + ExitProcess((UINT)status); +#else + avs_release_clip(clip); + avs_delete_script_environment(env); +#endif + if (diagnostic_log) { + log_checkpoint_int(diagnostic_log, "exit: ", status); + fclose(diagnostic_log); + } + return status; +} diff --git a/tools/avisynth_runner/mbt_avs_runner.exe b/tools/avisynth_runner/mbt_avs_runner.exe new file mode 100755 index 0000000000000000000000000000000000000000..79adfaa851716cce0c317832e062e15228a0fc78 GIT binary patch literal 273938 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 B0=57E literal 0 HcmV?d00001 diff --git a/tools/avisynth_runner/mbt_avs_runner32.exe b/tools/avisynth_runner/mbt_avs_runner32.exe new file mode 100755 index 0000000000000000000000000000000000000000..6f2f5cabed6891224e95b96717ba3590e62720c4 GIT binary patch literal 261445 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 literal 0 HcmV?d00001 diff --git a/tools/avisynth_validate.py b/tools/avisynth_validate.py new file mode 100644 index 0000000..679289e --- /dev/null +++ b/tools/avisynth_validate.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import csv +from dataclasses import dataclass, field +from pathlib import Path + + +IDENTITY_KEYS = ("mbt_source_id", "mbt_source_frame", "mbt_drop_frame") + + +@dataclass(frozen=True) +class FrameIdentity: + output_frame: int + source_id: int | None + source_frame: int | None + drop_frame: bool = False + matrix: int | None = None + + +@dataclass(frozen=True) +class IdentityValidationResult: + accepted: bool + frame_count: int + kept_frame_count: int + dropped_frame_count: int + source_ids: list[int] + issues: list[str] = field(default_factory=list) + + +def validate_frame_identities( + frames: list[FrameIdentity], + *, + expected_source_ids: set[int] | None = None, +) -> IdentityValidationResult: + issues: list[str] = [] + source_ids: set[int] = set() + seen_refs: set[tuple[int, int]] = set() + seen_output_frames: set[int] = set() + last_ref: tuple[int, int] | None = None + kept_count = 0 + dropped_count = 0 + + for frame in frames: + if frame.output_frame < 0: + issues.append(f"output frame {frame.output_frame} is negative") + if frame.output_frame in seen_output_frames: + issues.append(f"output frame {frame.output_frame} appears more than once") + seen_output_frames.add(frame.output_frame) + + if frames: + max_output_frame = max(frame.output_frame for frame in frames) + missing_output_frames = sorted( + set(range(max_output_frame + 1)) - seen_output_frames + ) + if missing_output_frames: + issues.append( + "missing output frame row(s): " + _format_int_list(missing_output_frames) + ) + + for frame in sorted(frames, key=lambda item: item.output_frame): + if frame.source_id is None: + issues.append(f"output frame {frame.output_frame} is missing mbt_source_id") + continue + if frame.source_frame is None: + issues.append(f"output frame {frame.output_frame} is missing mbt_source_frame") + continue + if frame.source_frame < 0: + issues.append(f"output frame {frame.output_frame} has a negative source frame") + continue + if expected_source_ids is not None and frame.source_id not in expected_source_ids: + issues.append( + f"output frame {frame.output_frame} references unknown source id " + f"{frame.source_id}" + ) + continue + + source_ids.add(frame.source_id) + ref = (frame.source_id, frame.source_frame) + + if ref in seen_refs: + issues.append( + f"output frame {frame.output_frame} duplicates source " + f"{frame.source_id}:{frame.source_frame}" + ) + seen_refs.add(ref) + + if last_ref is not None and ref < last_ref: + issues.append( + f"output frame {frame.output_frame} reorders source frames " + f"from {last_ref[0]}:{last_ref[1]} to {ref[0]}:{ref[1]}" + ) + last_ref = ref + + if frame.drop_frame: + dropped_count += 1 + continue + + kept_count += 1 + + return IdentityValidationResult( + accepted=not issues, + frame_count=len(frames), + kept_frame_count=kept_count, + dropped_frame_count=dropped_count, + source_ids=sorted(source_ids), + issues=issues, + ) + + +def read_frame_identity_csv(path: Path) -> list[FrameIdentity]: + frames: list[FrameIdentity] = [] + with path.open(newline="", encoding="utf-8") as handle: + reader = csv.reader(handle) + for line_number, row in enumerate(reader, start=1): + if line_number == 1 and row in ( + ["output_frame", "source_id", "source_frame", "drop_frame"], + ["output_frame", "source_id", "source_frame", "drop_frame", "matrix"], + ): + continue + if not row or all(not value.strip() for value in row): + continue + if len(row) not in {4, 5}: + raise RuntimeError( + f"{path}:{line_number}: expected 4 or 5 CSV columns, got {len(row)}" + ) + try: + output_frame = int(row[0]) + source_id = _optional_int(row[1]) + source_frame = _optional_int(row[2]) + drop_frame = bool(int(row[3] or "0")) + matrix = _optional_int(row[4]) if len(row) == 5 else None + except ValueError as exc: + raise RuntimeError( + f"{path}:{line_number}: invalid frame identity row: {row}" + ) from exc + frames.append( + FrameIdentity( + output_frame=output_frame, + source_id=source_id, + source_frame=source_frame, + drop_frame=drop_frame, + matrix=matrix, + ) + ) + return frames + + +def _optional_int(value: str) -> int | None: + value = value.strip() + return int(value) if value else None + + +def _format_int_list(values: list[int]) -> str: + if len(values) <= 10: + return ", ".join(str(value) for value in values) + head = ", ".join(str(value) for value in values[:10]) + return f"{head}, ... ({len(values)} total)" diff --git a/tools/avisynth_workspace.py b/tools/avisynth_workspace.py new file mode 100644 index 0000000..3758c59 --- /dev/null +++ b/tools/avisynth_workspace.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +import os +import shutil +import sys +import time +from datetime import timedelta, timezone +from pathlib import Path +from statistics import median + +from tools.video_formatting import ( + format_frame_count, + format_framerate, + format_seconds, + join_parts, + video_parts, +) +from tools.video_inputs import VideoInput +from tools.video_probe import VideoProbe + + +WORKSPACE_DIR = Path("video_workspace") +SOURCE_DIR = ".source" +HELPER_SCRIPT_NAME = "avisynth_helpers.avs" + + +def workspace_root(script_path: Path) -> Path: + return script_path.resolve().parent / WORKSPACE_DIR + + +def visible_script_path(root: Path, item: VideoInput) -> Path: + return root / item.collapsed_relative.with_name( + f"{item.collapsed_relative.name}.avs" + ) + + +def hidden_source_script_path(root: Path, item: VideoInput) -> Path: + return _source_sidecar_path(root, item, ".source.avs") + + +def frame_timestamp_values_path(root: Path, item: VideoInput) -> Path: + return _source_sidecar_path(root, item, ".timestamps.txt") + + +def frame_time_values_path(root: Path, item: VideoInput) -> Path: + return _source_sidecar_path(root, item, ".frame-times.txt") + + +def frame_time_label_values_path(root: Path, item: VideoInput) -> Path: + return _source_sidecar_path(root, item, ".frame-time-labels.txt") + + +def frame_duration_values_path(root: Path, item: VideoInput) -> Path: + return _source_sidecar_path(root, item, ".durations.txt") + + +def frame_duration_label_values_path(root: Path, item: VideoInput) -> Path: + return _source_sidecar_path(root, item, ".duration-labels.txt") + + +def validation_script_path(root: Path, item: VideoInput) -> Path: + return _source_sidecar_path(root, item, ".validate.avs") + + +def validation_csv_path(root: Path, item: VideoInput) -> Path: + return _source_sidecar_path(root, item, ".frames.csv") + + +def editable_audio_script_path(root: Path, item: VideoInput) -> Path: + visible = visible_script_path(root, item) + return visible.with_name(f"{visible.stem}_audio.avs") + + +def rendered_audio_wav_path(root: Path, item: VideoInput) -> Path: + return _source_sidecar_path(root, item, ".audio.wav") + + +def _source_sidecar_path(root: Path, item: VideoInput, suffix: str) -> Path: + return root / SOURCE_DIR / item.collapsed_relative.with_name( + f"{item.collapsed_relative.name}{suffix}" + ) + + +def existing_visible_scripts(root: Path, items: list[VideoInput]) -> list[Path]: + return [path for path in (visible_script_path(root, item) for item in items) if path.exists()] + + +def write_workspace( + *, + root: Path, + items: list[VideoInput], + probes_by_path: dict[Path, VideoProbe], + overwrite_visible: bool | set[Path], + ffms2_plugin_path: Path | None, +) -> tuple[list[Path], list[Path]]: + written: list[Path] = [] + skipped_visible: list[Path] = [] + + source_root = root / SOURCE_DIR + _clear_source_root(source_root) + + helper_script = helper_script_path() + + for source_id, item in enumerate(items): + probe = probes_by_path[item.path.resolve()] + source_script = hidden_source_script_path(root, item) + timestamp_values = frame_timestamp_values_path(root, item) + frame_time_values = frame_time_values_path(root, item) + frame_time_label_values = frame_time_label_values_path(root, item) + duration_values = frame_duration_values_path(root, item) + duration_label_values = frame_duration_label_values_path(root, item) + validation_script = validation_script_path(root, item) + visible_script = visible_script_path(root, item) + + source_script.parent.mkdir(parents=True, exist_ok=True) + visible_script.parent.mkdir(parents=True, exist_ok=True) + + timestamp_values.write_text(_timestamp_values_file(probe), encoding="utf-8") + frame_time_values.write_text(_frame_time_values_file(probe), encoding="utf-8") + frame_time_label_values.write_text( + _frame_time_label_values_file(probe), encoding="utf-8" + ) + duration_values.write_text(_duration_values_file(probe), encoding="utf-8") + duration_label_values.write_text(_duration_label_values_file(probe), encoding="utf-8") + source_script.write_text( + _hidden_source_script( + item, + probe, + source_id, + source_script, + helper_script, + ffms2_plugin_path, + timestamp_values, + frame_time_values, + frame_time_label_values, + duration_values, + duration_label_values, + ), + encoding="utf-8", + ) + validation_script.write_text( + _validation_script(visible_script, validation_script), + encoding="utf-8", + ) + written.extend([ + timestamp_values, + frame_time_values, + frame_time_label_values, + duration_values, + duration_label_values, + source_script, + validation_script, + ]) + + if visible_script.exists() and not _should_overwrite_visible(visible_script, overwrite_visible): + skipped_visible.append(visible_script) + else: + visible_script.write_text( + _visible_script( + visible_script, + source_script, + item, + probe, + ), + encoding="utf-8", + ) + written.append(visible_script) + + return written, skipped_visible + + +def _clear_source_root(source_root: Path) -> None: + if not source_root.exists(): + return + for attempt in range(5): + try: + shutil.rmtree(source_root) + return + except OSError as exc: + if attempt == 4: + raise RuntimeError( + f"Could not reset {source_root}. Close any program using files in " + "video_workspace/.source and try again." + ) from exc + time.sleep(0.25) + + +def _should_overwrite_visible(path: Path, overwrite_visible: bool | set[Path]) -> bool: + if isinstance(overwrite_visible, bool): + return overwrite_visible + return path.resolve() in overwrite_visible + + +def _visible_script( + visible_script: Path, + source_script: Path, + item: VideoInput, + probe: VideoProbe, +) -> str: + import_path = _avs_path(os.path.relpath(source_script, visible_script.parent)) + audio_script_name = f"{visible_script.stem}_audio.avs" + timing_kind = probe.timing.timing_kind.upper() + final_clip = _default_final_clip(probe) + windows_32bit_note = ( + "# End this file with a commented #32bit marker to use 32-bit AviSynth.\n" + if sys.platform == "win32" + else "" + ) + return ( + "# Don't touch.\n" + f'Import("{import_path}")\n' + "\n" + f"# Source: {item.path}\n" + f"# Video: {_format_video_summary(probe)}\n" + f"# Duration: {format_seconds(probe.duration_seconds)}, {_format_frame_count(probe)}\n" + f"# Framerate: {format_framerate(timing_kind, probe.avg_frame_rate)}\n" + "\n" + "# Trimmed frames also trim audio according to the timestamps of the trimmed frames.\n" + "# MBT_Drop marks frames for dropping without changing other frame timings.\n" + "# Frame properties must be preserved.\n" + "\n" + "# Bundled helper functions:\n" + "# MBT_Drop(last, int start, int \"end\"), MBT_Undrop(...)\n" + "# MBT_DropEvery(last, int cycle, int offset0, ...), MBT_UndropEvery(...)\n" + "# Similar syntax as Trim/SelectEvery. end defaults to -1, meaning one frame.\n" + "# Examples: MBT_Drop(last, 100), MBT_Drop(last, 100, -20),\n" + "# MBT_DropEvery(last, 4, 1, 3)\n" + "# MBT_Info(last, int \"size\", int \"align\")\n" + "# Overlay source frame, video time, absolute time, duration, and drop marker.\n" + "# Resize(clip v, int \"w\", int \"h\", float \"dar\", float \"xcrop\", float \"ycrop\", int \"pos\", bool \"linear\", string \"input_matrix\", string \"output_matrix\")\n" + "# Resize/crop. linear auto-enables for large downscales unless set explicitly.\n" + "# MBT_CorrectMatrix(last)\n" + "# Convert to the resolution-expected matrix using _Matrix as input metadata.\n" + "# DeShake(clip c, int \"w\", int \"h\", float \"dar\", float \"crop\", float \"xcrop\", float \"ycrop\", int \"pos\", bool \"zoom\", bool \"rot\", float \"freq\")\n" + "# Stabilize through MVTools + DePan; those plugins must be loaded separately.\n" + "# Cropf(...)\n" + "# Crop using fractional dimensions.\n" + "# RotateCrop(clip c, float angle, float \"dar\")\n" + "# Rotate with manyPlus Turn, then crop the largest background-free rectangle.\n" + "# FixContrast(...)\n" + "# Remap luma levels to limited-range video luma.\n" + "\n" + "# Optional audio edits:\n" + f"# Copy this script to {audio_script_name} next to this file.\n" + "# Edit that copy for audio fades/channel changes/etc. Its video output is ignored.\n" + "# Its audio output will be trimmed according to this script's frame edits.\n" + f"{windows_32bit_note}" + "\n" + "# User edits below this line. The imported source clip will already be in \"last\".\n" + "\n" + f"{final_clip}\n" + ) + + +def _hidden_source_script( + item: VideoInput, + probe: VideoProbe, + source_id: int, + source_script: Path, + helper_script: Path, + ffms2_plugin_path: Path | None, + timestamp_values: Path, + frame_time_values: Path, + frame_time_label_values: Path, + duration_values: Path, + duration_label_values: Path, +) -> str: + source_path = _avs_path(str(item.path.resolve())) + import_path = _avs_path(os.path.relpath(helper_script, source_script.parent)) + cache_path = _avs_path( + str(source_script.with_name(f"{item.collapsed_relative.name}.ffindex").resolve()) + ) + audio_cache_path = _avs_path( + str(source_script.with_name(f"{item.collapsed_relative.name}.audio.ffindex").resolve()) + ) + timestamp_values_path = _avs_path(str(timestamp_values.resolve())) + frame_time_values_path = _avs_path(str(frame_time_values.resolve())) + frame_time_label_values_path = _avs_path(str(frame_time_label_values.resolve())) + duration_values_path = _avs_path(str(duration_values.resolve())) + duration_label_values_path = _avs_path(str(duration_label_values.resolve())) + if ffms2_plugin_path is None: + plugin_setup = "# FFMS2 is expected from Avisynth autoload or a previously loaded plugin.\n" + else: + plugin_setup = f'LoadPlugin("{_avs_path(str(ffms2_plugin_path.resolve()))}")\n' + audio_setup = ( + "try {\n" + " mbt_video_only = mbt_video_only\n" + "} catch (err_msg) {\n" + " mbt_video_only = false\n" + "}\n" + f'source = mbt_video_only ? video : MBT_AudioSource(video, "{source_path}", "{audio_cache_path}")\n' + if probe.audio_stream_count > 0 + else "source = video\n" + ) + validation_blank_setup = ( + "try {\n" + " mbt_validation_blank = mbt_validation_blank\n" + "} catch (err_msg) {\n" + " mbt_validation_blank = false\n" + "}\n" + "video = mbt_validation_blank ? BlankClip(video, color=$000000) : video\n" + ) + return ( + "# Generated by media-batch-tools. This file is regenerated by video_encode.py.\n" + "# Do not put manual edits here; edit the matching script in video_workspace/.\n" + f"# Source: {source_path}\n" + f"# Source id: {source_id}\n" + f"# Detected timing: {probe.timing.timing_kind.upper()}\n" + "# Requires FFMS2 to provide FFVideoSource.\n" + "\n" + f"{plugin_setup}" + f'Import("{import_path}")\n' + "\n" + f'video = FFVideoSource("{source_path}", cachefile="{cache_path}")\n' + f"{validation_blank_setup}" + f"{audio_setup}" + f"last = MBT_MarkSource(source, {source_id}, {_matrix_code_for_source(probe)})\n" + f'last = ConditionalReader(last, "{timestamp_values_path}", "mbt_absolute_timestamp", false)\n' + f'last = ConditionalReader(last, "{frame_time_values_path}", "mbt_frame_time_seconds", false)\n' + f'last = ConditionalReader(last, "{frame_time_label_values_path}", "mbt_relative_timestamp", false)\n' + f'last = ConditionalReader(last, "{duration_values_path}", "mbt_frame_duration_seconds", false)\n' + f'last = ConditionalReader(last, "{duration_label_values_path}", "mbt_frame_duration", false)\n' + "last\n" + ) + + +def _validation_script( + visible_script: Path, + validation_script: Path, +) -> str: + import_path = _avs_path(os.path.relpath(visible_script, validation_script.parent)) + return ( + "# Generated by media-batch-tools. This file is regenerated by video_encode.py.\n" + "# The media-batch-tools runner reads frame identity properties from this clip.\n" + f'Import("{import_path}")\n' + "last\n" + ) + + +def _timestamp_values_file(probe: VideoProbe) -> str: + lines = [ + "# Generated by media-batch-tools for AviSynth ConditionalReader.", + "Type string", + "Default", + ] + if probe.metadata_end is None or probe.duration_seconds is None: + return "\n".join(lines) + "\n" + + source_end = probe.metadata_end + if source_end.tzinfo is None: + source_end = source_end.replace(tzinfo=timezone.utc) + source_begin = source_end - timedelta(seconds=probe.duration_seconds) + for frame, timestamp in enumerate(probe.frame_timestamps): + value = (source_begin + timedelta(seconds=timestamp)).astimezone(timezone.utc) + lines.append(f"{frame} {value.isoformat().replace('+00:00', 'Z')}") + return "\n".join(lines) + "\n" + + +def _frame_time_values_file(probe: VideoProbe) -> str: + lines = [ + "# Generated by media-batch-tools for AviSynth ConditionalReader.", + "Type float", + "Default 0.0", + ] + for frame, timestamp in enumerate(probe.frame_timestamps): + lines.append(f"{frame} {timestamp:.9f}") + return "\n".join(lines) + "\n" + + +def _frame_time_label_values_file(probe: VideoProbe) -> str: + lines = [ + "# Generated by media-batch-tools for AviSynth ConditionalReader.", + "Type string", + "Default 0.000000", + ] + for frame, timestamp in enumerate(probe.frame_timestamps): + lines.append(f"{frame} {_format_relative_timestamp(timestamp)}") + return "\n".join(lines) + "\n" + + +def _duration_values_file(probe: VideoProbe) -> str: + lines = [ + "# Generated by media-batch-tools for AviSynth ConditionalReader.", + "Type float", + "Default 0.0", + ] + for frame, duration in enumerate(_source_frame_durations(probe)): + lines.append(f"{frame} {duration:.9f}") + return "\n".join(lines) + "\n" + + +def _duration_label_values_file(probe: VideoProbe) -> str: + lines = [ + "# Generated by media-batch-tools for AviSynth ConditionalReader.", + "Type string", + "Default", + ] + for frame, duration in enumerate(_source_frame_durations(probe)): + lines.append(f"{frame} {_format_duration_label(duration)}") + return "\n".join(lines) + "\n" + + +def _format_duration_label(duration: float) -> str: + if duration <= 0: + return f"{duration:.6f} (1/inf)" + reciprocal = 1.0 / duration + return f"{duration:.6f} (1/{reciprocal:.2g})" + + +def _format_relative_timestamp(seconds: float) -> str: + if seconds < 60.0: + return f"{seconds:.6f}" + + total = int(seconds) + fraction = seconds - total + fractional = f"{fraction:.6f}"[1:] + if total < 3600: + minutes = total // 60 + remaining_seconds = total % 60 + return f"{minutes}:{remaining_seconds:02d}{fractional} ({seconds:.6f})" + + hours = total // 3600 + minutes = (total % 3600) // 60 + remaining_seconds = total % 60 + return f"{hours}:{minutes:02d}:{remaining_seconds:02d}{fractional} ({seconds:.6f})" + + +def _source_frame_durations(probe: VideoProbe) -> list[float]: + timestamps = probe.frame_timestamps + if not timestamps: + return [] + + durations: list[float] = [] + for current, next_timestamp in zip(timestamps, timestamps[1:]): + durations.append(max(0.0, next_timestamp - current)) + + fallback = median(durations) if durations else 0.0 + if probe.duration_seconds is not None and len(timestamps) > 1: + last_duration = probe.duration_seconds - timestamps[-1] + if last_duration <= 0: + last_duration = fallback + else: + last_duration = fallback + durations.append(max(0.0, last_duration)) + return durations + + +def _format_video_summary(probe: VideoProbe) -> str: + parts = video_parts( + width=probe.width, + height=probe.height, + pixel_format=probe.pixel_format, + bit_depth=probe.bit_depth, + ) + parts.append(_format_matrix(probe)) + return join_parts(parts) + + +def _format_matrix(probe: VideoProbe) -> str: + expected = probe.expected_color_matrix or "unknown expected" + if probe.color_matrix is not None: + if probe.color_space: + return f"{probe.color_matrix} ({probe.color_space}), expected {expected}" + return f"{probe.color_matrix}, expected {expected}" + if probe.color_space: + return f"{probe.color_space}, expected {expected}" + return f"unknown, expected {expected}" + + +def _matrix_code_for_source(probe: VideoProbe) -> int: + return _matrix_code(probe.color_matrix or probe.expected_color_matrix) + + +def _matrix_code(matrix: str | None) -> int: + if matrix == "Rec709": + return 1 + if matrix == "Rec2020": + return 9 + if matrix == "Rec601": + return 6 + return 2 + + +def _default_final_clip(probe: VideoProbe) -> str: + source_matrix = probe.color_matrix + expected_matrix = probe.expected_color_matrix + if source_matrix is None or expected_matrix is None or source_matrix == expected_matrix: + return "last" + return "MBT_CorrectMatrix(last) # source metadata differs from resolution default" + + +def _format_frame_count(probe: VideoProbe) -> str: + return format_frame_count(probe.stream_frame_count, len(probe.frame_timestamps)) + + +def _avs_path(path: str) -> str: + return path.replace("\\", "/").replace('"', '\\"') + + +def helper_script_path() -> Path: + return Path(__file__).resolve().with_name(HELPER_SCRIPT_NAME) diff --git a/tools/console.py b/tools/console.py index 06f2815..d4b82e5 100644 --- a/tools/console.py +++ b/tools/console.py @@ -1,6 +1,14 @@ from __future__ import annotations +import os import sys +import time +from collections import deque +from math import floor, log10 +from threading import Lock + + +_WINDOWS_ANSI_ENABLED: bool | None = None def prompt_input(prompt: str) -> str: @@ -10,9 +18,328 @@ def prompt_input(prompt: str) -> str: raise RuntimeError("Interactive input ended before choices were complete.") from exc +def prompt_yes_no(prompt: str, default: bool) -> bool: + suffix = "Y/n" if default else "y/N" + while True: + answer = prompt_input(f"{prompt} ({suffix}) ").strip().lower() + if not answer: + return default + if answer in {"y", "yes"}: + return True + if answer in {"n", "no"}: + return False + print("Please answer y or n.") + + def pause_if_interactive() -> None: if sys.stdin.isatty(): try: input("\nPress Enter to close...") except EOFError: pass + + +def supports_color() -> bool: + return not os.environ.get("NO_COLOR") and supports_ansi() + + +def supports_ansi() -> bool: + if not sys.stdout.isatty(): + return False + if os.name != "nt": + return os.environ.get("TERM", "dumb") != "dumb" + return _enable_windows_ansi() or bool( + os.environ.get("WT_SESSION") + or os.environ.get("ANSICON") + or os.environ.get("ConEmuANSI") == "ON" + or os.environ.get("TERM") + ) + + +def color(text: object, code: str) -> str: + value = str(text) + if not supports_color(): + return value + return f"\033[{code}m{value}\033[0m" + + +def light_green(text: object) -> str: + return color(text, "92") + + +def light_blue(text: object) -> str: + return color(text, "94") + + +def light_red(text: object) -> str: + return color(text, "91") + + +def red_strikethrough(text: object) -> str: + return color(text, "91;9") + + +def clear_screen() -> None: + if not sys.stdout.isatty(): + return + os.system("cls" if os.name == "nt" else "clear") + + +def _enable_windows_ansi() -> bool: + global _WINDOWS_ANSI_ENABLED + if _WINDOWS_ANSI_ENABLED is not None: + return _WINDOWS_ANSI_ENABLED + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) + mode = ctypes.c_uint32() + if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + _WINDOWS_ANSI_ENABLED = False + return False + _WINDOWS_ANSI_ENABLED = bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004)) + return _WINDOWS_ANSI_ENABLED + except Exception: + _WINDOWS_ANSI_ENABLED = False + return False + + +def format_duration(seconds: float) -> str: + seconds = max(0, int(round(seconds))) + minutes, second = divmod(seconds, 60) + hours, minute = divmod(minutes, 60) + if hours: + return f"{hours}:{minute:02d}:{second:02d}" + return f"{minute}:{second:02d}" + + +class ProgressView: + def __init__( + self, + total: int, + label: str, + *, + stream=None, + embedded_percent: bool = False, + show_rate: bool = False, + ) -> None: + self.total = max(0, total) + self.label = label + self.stream = stream or sys.stdout + self.started_at = time.monotonic() + self.samples: deque[tuple[int, float]] = deque() + self.last_width = 0 + self.embedded_percent = embedded_percent + self.show_rate = show_rate + + def update(self, processed: int, detail: str = "") -> None: + self._write(self.update_line(processed, detail)) + + def update_line(self, processed: int, detail: str = "") -> str: + processed = max(0, min(processed, self.total)) + now = time.monotonic() + if not self.samples or self.samples[-1][0] != processed: + self.samples.append((processed, now)) + while len(self.samples) > max(2, processed // 2 + 2): + self.samples.popleft() + return self._line(processed, detail, now) + + def update_external( + self, + processed: int, + *, + elapsed: float, + eta: float | None, + detail: str = "", + ) -> None: + self._write(self.update_external_line(processed, elapsed=elapsed, eta=eta, detail=detail)) + + def update_external_line( + self, + processed: int, + *, + elapsed: float, + eta: float | None, + detail: str = "", + ) -> str: + processed = max(0, min(processed, self.total)) + return self._line(processed, detail, None, elapsed=elapsed, eta=eta) + + def finish(self, *, keep: bool = False) -> None: + if self.stream.isatty(): + if keep: + self.stream.write("\n") + else: + self.stream.write("\r" + " " * self.last_width + "\r") + self.stream.flush() + elif self.last_width and not keep: + self.stream.write("\n") + self.last_width = 0 + + def _write(self, line: str) -> None: + if self.stream.isatty(): + padding = max(0, self.last_width - len(line)) + self.stream.write("\r" + line + (" " * padding)) + self.stream.flush() + self.last_width = len(line) + else: + self.stream.write(line + "\n") + self.last_width = len(line) + + def _line( + self, + processed: int, + detail: str, + now: float | None, + *, + elapsed: float | None = None, + eta: float | None = None, + ) -> str: + prefix = self.label if not detail else f"{self.label}: {detail}" + if self.total <= 2: + return f"{prefix} {processed}/{self.total}" + + percent = 0.0 if self.total == 0 else processed / self.total + bar = _progress_bar(percent, embedded_percent=self.embedded_percent) + elapsed_value = elapsed if elapsed is not None else (now or time.monotonic()) - self.started_at + parts = [ + prefix, + bar, + f"{processed}/{self.total}", + ] + if not self.embedded_percent: + parts.append(f"{percent * 100:5.1f}%") + if self.show_rate and processed > 0 and elapsed_value > 0: + parts.append(f"{_format_significant(processed / elapsed_value, 2)} fps") + parts.append(f"elapsed {format_duration(elapsed_value)}") + eta_value = eta if eta is not None else self._eta(processed) + if processed >= 2 and processed < self.total and eta_value is not None: + parts.append(f"ETA {format_duration(eta_value)}") + return " ".join(parts) + + def _eta(self, processed: int) -> float | None: + if processed < 2 or self.total <= processed or len(self.samples) < 2: + return None + oldest_processed, oldest_time = self.samples[0] + newest_processed, newest_time = self.samples[-1] + delta_items = newest_processed - oldest_processed + delta_time = newest_time - oldest_time + if delta_items <= 0 or delta_time <= 0: + return None + return (self.total - processed) * (delta_time / delta_items) + + +class PipelineProgressView: + def __init__( + self, + *, + rendering_label: str, + encoding_label: str, + encoding_total: int, + stream=None, + ) -> None: + self.stream = stream or sys.stdout + self.rendering_label = rendering_label + self.encoding = ProgressView( + encoding_total, + encoding_label, + stream=self.stream, + embedded_percent=True, + show_rate=True, + ) + self.rendering: ProgressView | None = None + self.rendering_line = f"{rendering_label} starting..." + self.encoding_line = self.encoding.update_line(0) + self._shown = False + self._last_width = 0 + self._ansi = supports_ansi() + self._lock = Lock() + + def update_rendering( + self, + processed: int, + total: int, + *, + elapsed: float, + eta: float | None, + ) -> None: + with self._lock: + if self.rendering is None or self.rendering.total != total: + self.rendering = ProgressView( + total, + self.rendering_label, + stream=self.stream, + embedded_percent=True, + show_rate=True, + ) + self.rendering_line = self.rendering.update_external_line( + processed, + elapsed=elapsed, + eta=eta if processed >= 2 else None, + ) + self._write_locked() + + def update_encoding(self, processed: int) -> None: + with self._lock: + self.encoding_line = self.encoding.update_line(processed) + self._write_locked() + + def finish(self) -> None: + with self._lock: + if not self._shown: + return + if self._ansi: + self.stream.write("\n") + else: + self.stream.write("\r" + " " * self._last_width + "\r") + self.stream.flush() + self._shown = False + + def _write_locked(self) -> None: + if self._ansi: + if self._shown: + self.stream.write("\x1b[1F") + self.stream.write("\r\x1b[2K" + self.rendering_line + "\n") + self.stream.write("\r\x1b[2K" + self.encoding_line) + else: + line = f"{self.rendering_line} | {self.encoding_line}" + padding = max(0, self._last_width - len(line)) + self.stream.write("\r" + line + " " * padding) + self._last_width = len(line) + self.stream.flush() + self._shown = True + + +def _progress_bar(percent: float, *, embedded_percent: bool) -> str: + width = 24 + done = round(max(0.0, min(percent, 1.0)) * width) + if not embedded_percent: + return "[" + ("#" * done).ljust(width, "-") + "]" + + remaining = width - done + percent_text = f"{percent * 100:.1f}%" + if percent >= 0.5: + left = _center_progress_text(percent_text, done, "#") + right = "-" * remaining + else: + left = "#" * done + right = _center_progress_text(percent_text, remaining, "-") + return f"[{left}{right}]" + + +def _center_progress_text(text: str, width: int, fill: str) -> str: + content = f" {text} " + if len(content) > width: + return fill * width + return content.center(width, fill) + + +def _format_significant(value: float, digits: int) -> str: + if value == 0: + return "0" + decimals = digits - floor(log10(abs(value))) - 1 + if decimals > 0: + return f"{value:.{decimals}f}" + factor = 10 ** -decimals + return str(int(round(value / factor) * factor)) diff --git a/tools/executables.py b/tools/executables.py new file mode 100644 index 0000000..638f115 --- /dev/null +++ b/tools/executables.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from shutil import which + +from tools.console import prompt_input + + +def require_executable(name: str) -> str: + executable = which(name) + if executable: + return executable + fallback = _windows_default_executable(name) + if fallback is not None: + print(f"\n{name} was not found on PATH; using {fallback}.") + return str(fallback) + if not sys.stdin.isatty(): + raise RuntimeError(_missing_tool_message(name)) + + print(f"\n{name} was not found on PATH.") + while True: + answer = prompt_input( + f"Absolute path to {name} for this run (blank = cancel): " + ).strip().strip('"') + if not answer: + raise RuntimeError(_missing_tool_message(name)) + path = Path(answer).expanduser() + if not path.is_absolute(): + print("Please enter an absolute path.") + elif not path.is_file(): + print(f"File not found: {path}") + else: + return str(path.resolve()) + + +def _missing_tool_message(name: str) -> str: + return ( + f"{name} was not found on PATH. Install it and make sure the " + f"'{name}' command works before running this script." + ) + + +def _windows_default_executable(name: str) -> Path | None: + if os.name != "nt" or name.casefold().removesuffix(".exe") != "mkvmerge": + return None + + path = Path(r"C:\Program Files\MKVToolNix\mkvmerge.exe") + return path.resolve() if path.is_file() else None diff --git a/tools/exiftool.py b/tools/exiftool.py index 73be4a6..410c99f 100644 --- a/tools/exiftool.py +++ b/tools/exiftool.py @@ -1,36 +1,69 @@ from __future__ import annotations import json +import os import subprocess +import tempfile from pathlib import Path -from shutil import which +from typing import Any + +from tools.executables import require_executable def require_exiftool() -> str: - executable = which("exiftool") - if executable: - return executable - raise RuntimeError( - "exiftool was not found on PATH. Install ExifTool and make sure the " - "'exiftool' command works before running this script." - ) + return require_executable("exiftool") -def run_exiftool_json(executable: str, paths: list[Path]) -> list[dict]: +def _use_windows_argument_file() -> bool: + return os.name == "nt" + + +def run_exiftool_command( + executable: str, + args: list[str], + **run_kwargs: Any, +) -> subprocess.CompletedProcess: + """Run ExifTool with Unicode-safe filename arguments on Windows.""" + command = [executable, "-charset", "filename=UTF8", *args] + if not _use_windows_argument_file(): + return subprocess.run(command, **run_kwargs) + + # ExifTool can't reliably receive arbitrary Unicode paths on the Windows + # command line. The outer path is ASCII and the UTF-8 argument file holds + # the actual paths and options. + with tempfile.TemporaryDirectory(prefix="mbt-exiftool-") as temp_dir: + argument_file = Path(temp_dir) / "arguments.txt" + argument_file.write_text("\n".join(args) + "\n", encoding="utf-8") + return subprocess.run( + [executable, "-charset", "filename=UTF8", "-@", argument_file.name], + cwd=temp_dir, + **run_kwargs, + ) + + +def run_exiftool_json( + executable: str, + paths: list[Path], + *, + quicktime_utc: bool = True, +) -> list[dict]: results: list[dict] = [] for start in range(0, len(paths), 80): chunk = paths[start : start + 80] - command = [ - executable, + args = [ "-j", "-G", - "-api", - "QuickTimeUTC=1", - "-charset", - "filename=UTF8", - *[str(path) for path in chunk], ] - completed = subprocess.run(command, check=True, capture_output=True, text=True) + if quicktime_utc: + args.extend(["-api", "QuickTimeUTC=1"]) + args.extend(str(path) for path in chunk) + completed = run_exiftool_command( + executable, + args, + check=True, + capture_output=True, + text=True, + ) results.extend(json.loads(completed.stdout or "[]")) return results @@ -39,11 +72,8 @@ def run_exiftool_write(executable: str, path: Path, args: list[str]) -> None: if not args: return command = [ - executable, "-overwrite_original", - "-charset", - "filename=UTF8", *args, str(path), ] - subprocess.run(command, check=True) + run_exiftool_command(executable, command, check=True) diff --git a/tools/filenames.py b/tools/filenames.py index 2df8ebb..e6d4d6e 100644 --- a/tools/filenames.py +++ b/tools/filenames.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path @@ -16,6 +16,18 @@ def format_filename_stem(dt: datetime) -> str: return naive_wall_time(dt).strftime("%Y%m%d_%H%M%S") +def round_datetime_to_second(dt: datetime) -> datetime: + rounded = dt.replace(microsecond=0) + if dt.microsecond >= 500_000: + rounded += timedelta(seconds=1) + return rounded + + +def format_rounded_filename_stem(dt: datetime) -> str: + rounded = round_datetime_to_second(dt) + return format_filename_stem(rounded) + + def normalized_extension(path: Path) -> str: return path.suffix.lower() diff --git a/tools/metadata_copy.py b/tools/metadata_copy.py new file mode 100644 index 0000000..ca86dd5 --- /dev/null +++ b/tools/metadata_copy.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +from tools.exiftool import run_exiftool_command + + +EXCLUDED_COPY_TAGS = [ + "--File:all", + "--ExifTool:all", + "--Composite:all", + "--SourceFile", + "--Directory", + "--FileName", + "--FileSize", + "--FileModifyDate", + "--FileAccessDate", + "--FileInodeChangeDate", + "--FilePermissions", + "--FileType", + "--FileTypeExtension", + "--MIMEType", + "--ImageWidth", + "--ImageHeight", + "--ExifImageWidth", + "--ExifImageHeight", + "--PixelXDimension", + "--PixelYDimension", + "--RelatedImageWidth", + "--RelatedImageHeight", + "--ImageSize", + "--Megapixels", + "--XResolution", + "--YResolution", + "--ResolutionUnit", + "--Duration", + "--DurationScale", + "--DurationValue", + "--MediaDuration", + "--MediaTimeScale", + "--TrackDuration", + "--TrackCreateDate", + "--TrackModifyDate", + "--MediaCreateDate", + "--MediaModifyDate", + "--AvgBitrate", + "--Bitrate", + "--MaxBitrate", + "--VideoFrameRate", + "--CaptureFrameRate", + "--AudioBitrate", + "--AudioSampleRate", + "--AudioSamplingRate", + "--AudioBitsPerSample", + "--AudioChannels", + "--CompressorID", + "--CompressorName", + "--MediaDataOffset", + "--MediaDataSize", +] + + +def copy_meaningful_metadata( + exiftool: str, + source: Path, + destination: Path, +) -> None: + if destination.suffix.lower() in {".mp4", ".mov"}: + copy_meaningful_metadata_with_exiftool(exiftool, source, destination) + return + copy_container_metadata_with_ffmpeg(source, destination) + + +def copy_meaningful_metadata_with_exiftool( + exiftool: str, + source: Path, + destination: Path, +) -> None: + args = [ + "-overwrite_original", + "-TagsFromFile", + str(source), + "-all:all", + *EXCLUDED_COPY_TAGS, + str(destination), + ] + run_exiftool_command( + exiftool, + args, + check=True, + capture_output=True, + text=True, + ) + + +def copy_container_metadata_with_ffmpeg(source: Path, destination: Path) -> None: + ffmpeg = shutil.which("ffmpeg") + if not ffmpeg: + print(" metadata: ffmpeg not found; skipped best-effort metadata remux") + return + + temp_output = destination.with_name(f"{destination.stem}.metadata-copy{destination.suffix}") + temp_output = _unique_path(temp_output) + command = [ + ffmpeg, + "-y", + "-hide_banner", + "-loglevel", + "error", + "-i", + str(destination), + "-i", + str(source), + "-map", + "0", + "-map_metadata", + "1", + "-c", + "copy", + str(temp_output), + ] + try: + subprocess.run(command, check=True, capture_output=True, text=True) + temp_output.replace(destination) + finally: + if temp_output.exists(): + temp_output.unlink() + + +def _unique_path(path: Path) -> Path: + if not path.exists(): + return path + for index in range(1, 10000): + candidate = path.with_name(f"{path.stem}-{index}{path.suffix}") + if not candidate.exists(): + return candidate + raise RuntimeError(f"Could not find a unique temporary path for {path}") diff --git a/tools/timezones.py b/tools/timezones.py new file mode 100644 index 0000000..bf86e2b --- /dev/null +++ b/tools/timezones.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import re +from datetime import datetime, timedelta, timezone + + +TZ_RE = re.compile(r"^(?P<sign>[+-])(?P<h>\d{2})(?::?(?P<m>\d{2}))?$") + + +def local_timezone() -> timezone: + offset = datetime.now().astimezone().utcoffset() + if offset is None: + return timezone.utc + return timezone(offset) + + +def parse_timezone_offset(value: str) -> timezone: + match = TZ_RE.match(value.strip()) + if not match: + raise ValueError("timezone must look like +09:00 or -05:30") + sign = 1 if match.group("sign") == "+" else -1 + hours = int(match.group("h")) + minutes = int(match.group("m") or "0") + if minutes not in {0, 15, 30, 45}: + raise ValueError("timezone minutes must be 00, 15, 30, or 45") + return timezone(sign * timedelta(hours=hours, minutes=minutes)) + + +def timezone_to_string(tz: timezone) -> str: + offset = tz.utcoffset(None) + if offset is None: + return "+00:00" + total_minutes = int(offset.total_seconds() // 60) + sign = "+" if total_minutes >= 0 else "-" + total_minutes = abs(total_minutes) + return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}" diff --git a/tools/video_batch_encode.py b/tools/video_batch_encode.py new file mode 100644 index 0000000..557ae52 --- /dev/null +++ b/tools/video_batch_encode.py @@ -0,0 +1,999 @@ +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from pathlib import Path + +from tools.avisynth_render import AvisynthClipInfo +from tools.avisynth_runner import AvisynthRunnerSet +from tools.avisynth_workspace import ( + editable_audio_script_path, + rendered_audio_wav_path, + visible_script_path, +) +from tools.console import clear_screen, light_red, prompt_input, prompt_yes_no, red_strikethrough +from tools.filenames import format_rounded_filename_stem +from tools.timezones import local_timezone, parse_timezone_offset +from tools.timezones import timezone_to_string +from tools.video_encode_output import ( + ENCODER_PRESETS, + AudioEncodeOptions, + VideoCodecOptions, + choose_video_pixel_format, + default_audio_bitrate, + encode_video_only_y4m, + ffmpeg_colorspace_from_matrix, + pixel_format_bit_depth, + print_encoder_statistics, + render_audio_wav, +) +from tools.video_timestamp_encode import encode_video_with_timestamps +from tools.video_encode_plan import ( + audio_options_for_probe, + audio_tempo_factor, + avisynth_duration_differs, + encoded_duration_seconds, + has_avisynth_speed_changes, + normal_deleted_frame_count, + should_preserve_video_timestamps, + should_use_audio_segments, + timeline_frame_starts, + y4m_command_for_timeline, +) +from tools.video_codec_constraints import ( + VideoConstraintSpec, + available_levels, + available_profiles, + level_supports, + profile_supports, + resolve_level, + resolve_profile, +) +from tools.video_inputs import VideoInput +from tools.video_formatting import chroma_family, matrix_name +from tools.video_options import ( + OutputNamingOptions, + ask_audio_options, + ask_output_naming_options, + ask_video_codec_options, +) +from tools.video_outputs import ( + EncodedItem, + copy_video_metadata, + finalize_encoded_outputs, + output_begin_timestamp, + planned_output_path, + validate_encoded_video, + write_video_end_timestamp, +) +from tools.video_probe import VideoProbe, require_tool +from tools.video_reporting import format_validation_summary_lines +from tools.video_timeline import OutputTimeline + + +@dataclass(frozen=True) +class EncodingOutputSpec: + label: str + width: int + height: int + bit_depth: int + chroma: str + fps: float + begin_utc: str + summary_lines: tuple[str, ...] + + +@dataclass(frozen=True) +class EncodingSettings: + naming: OutputNamingOptions + codec_options: VideoCodecOptions + audio_options: AudioEncodeOptions + + +def _plan_batch_outputs( + *, + inputs: list[VideoInput], + root: Path, + timelines: dict[Path, OutputTimeline], + probes_by_path: dict[Path, VideoProbe], + naming: OutputNamingOptions, + extension: str, +) -> dict[Path, Path]: + def plan(*, overwrite_existing: bool) -> dict[Path, Path]: + reserved: set[Path] = set() + return { + item.path.resolve(): planned_output_path( + script=visible_script_path(root, item), + timeline=timelines[item.path.resolve()], + probe=probes_by_path[item.path.resolve()], + naming=naming, + extension=extension, + planned_outputs=reserved, + overwrite_existing=overwrite_existing, + ) + for item in inputs + } + + output_paths = plan(overwrite_existing=True) + existing = sorted({path for path in output_paths.values() if path.exists()}) + if not existing: + return output_paths + + print("\nExisting output video file(s):") + for path in existing: + print(f" - {path}") + overwrite = ( + prompt_yes_no("Overwrite existing output video file(s)?", default=True) + if sys.stdin.isatty() + else True + ) + return output_paths if overwrite else plan(overwrite_existing=False) + + +def encode_validated_video_only( + root: Path, + inputs: list[VideoInput], + timelines: dict[Path, OutputTimeline], + clip_infos: dict[Path, AvisynthClipInfo], + probes_by_path: dict[Path, VideoProbe], + exiftool: str, + avs_runners: AvisynthRunnerSet, + ffprobe: str, +) -> None: + inputs = [item for item in inputs if item.path.resolve() in timelines] + if not inputs: + print("\nNo validated outputs to encode.") + return + ffmpeg = Path(require_tool("ffmpeg")).resolve() + default_timezone = _default_output_timezone( + [probes_by_path[item.path.resolve()] for item in inputs] + ) + if sys.stdin.isatty() and not _encoding_env_is_set(): + settings = ask_interactive_encoding_settings( + inputs=inputs, + timelines=timelines, + clip_infos=clip_infos, + probes_by_path=probes_by_path, + ) + if settings is None: + print("\nEncoding skipped.") + return + naming = settings.naming + codec_options = settings.codec_options + audio_options = settings.audio_options + else: + naming = ask_output_naming_options(default_timezone=default_timezone) + codec_options = ask_video_codec_options() + audio_options = ask_audio_options( + has_speed_changes=has_avisynth_speed_changes(timelines, clip_infos), + container_extension=codec_options.extension, + ) + specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path) + if codec_options.codec == "libx264" and any(spec.bit_depth > 10 for spec in specs): + raise RuntimeError("x264 cannot encode this batch because at least one output is above 10-bit") + needs_timecodes = any( + should_preserve_video_timestamps(timelines[item.path.resolve()]) + for item in inputs + ) + mkvmerge = Path(require_tool("mkvmerge")).resolve() if needs_timecodes else None + x264 = ( + Path(require_tool("x264")).resolve() + if needs_timecodes and codec_options.codec == "libx264" + else None + ) + print(f"\nAvisynth runner: {avs_runners.default}") + print(f"FFmpeg: {ffmpeg}") + if mkvmerge is not None: + print(f"mkvmerge: {mkvmerge}") + if x264 is not None: + print(f"x264: {x264}") + print( + "Output naming: " + + ( + f"timestamps in {timezone_to_string(naming.timezone_value)}" + if naming.use_timestamps + else "original names" + ) + ) + print( + "Codec: " + f"{codec_options.codec}, CRF {codec_options.crf}, " + f"preset {codec_options.preset}, " + f"profile {codec_options.profile or 'none'}, " + f"level {codec_options.level or 'none'}, " + f"threads {codec_options.threads if codec_options.threads is not None else 'auto'}, " + f"{codec_options.extension}" + ) + print(f"Audio: {_format_audio_options(audio_options)}") + + output_paths = _plan_batch_outputs( + inputs=inputs, + root=root, + timelines=timelines, + probes_by_path=probes_by_path, + naming=naming, + extension=codec_options.extension, + ) + encoded_items: list[EncodedItem] = [] + for item_index, item in enumerate(inputs, start=1): + timeline = timelines[item.path.resolve()] + script = visible_script_path(root, item) + avs_runner = avs_runners.for_script(script) + print(f"\n{item.collapsed_relative}") + if avs_runner != avs_runners.default: + print(f" runner: {avs_runner}") + clip_info = clip_infos.get(item.path.resolve()) + output_bit_depth = clip_info.bit_depth if clip_info is not None else 8 + pixel_format = choose_video_pixel_format( + ffmpeg, + codec_options.codec, + output_bit_depth, + clip_info.chroma if clip_info is not None else None, + ) + spec = _constraint_spec_for_item(item, timelines, clip_infos, probes_by_path) + encoded_spec = VideoConstraintSpec( + width=spec.width, + height=spec.height, + bit_depth=pixel_format_bit_depth(pixel_format), + chroma=spec.chroma, + fps=spec.fps, + ) + effective_codec_options = _resolve_codec_options(codec_options, encoded_spec) + if effective_codec_options.profile or effective_codec_options.level: + print( + " constraints: " + f"profile {effective_codec_options.profile or 'none'}, " + f"level {effective_codec_options.level or 'none'}" + ) + colorspace = ffmpeg_colorspace_from_matrix(timeline.matrix) + if output_bit_depth >= 10: + encoded_bit_depth = pixel_format_bit_depth(pixel_format) + print( + f" video: {output_bit_depth}-bit Avisynth output -> " + f"{encoded_bit_depth}-bit {pixel_format}" + ) + if colorspace is not None: + print(f" color space: {matrix_name(timeline.matrix)} -> ffmpeg {colorspace}") + normal_deleted_frames = normal_deleted_frame_count(timeline) + preserve_video_timestamps = should_preserve_video_timestamps(timeline) + if not preserve_video_timestamps and timeline.timing_kind != "cfr": + print(" skipped encode: output timeline is not CFR") + continue + if not preserve_video_timestamps and timeline.dropped_frame_count and ( + clip_info is not None and avisynth_duration_differs(clip_info, timeline) + ): + print( + " skipped encode: drop_frame plus Avisynth speed change needs " + "timestamp-aware muxing" + ) + continue + probe = probes_by_path[item.path.resolve()] + output_duration = ( + timeline.duration_seconds + if preserve_video_timestamps + else encoded_duration_seconds(clip_info, timeline) + ) + audio_tempo = audio_tempo_factor(timeline, output_duration) + output = output_paths[item.path.resolve()] + effective_audio_options = audio_options_for_probe( + audio_options, + probe, + timeline, + output_duration, + normal_deleted_frames=normal_deleted_frames, + ) + audio_segments = ( + timeline.audio_segments + if should_use_audio_segments(timeline, effective_audio_options) + else None + ) + audio_source, source_has_audio = prepare_audio_source( + avs_runners=avs_runners, + root=root, + item=item, + original_has_audio=probe.audio_stream_count > 0, + audio_options=effective_audio_options, + ) + encode_kwargs = dict( + y4m_command=y4m_command_for_timeline( + avs_runner, + timeline, + force_timeline_rate=preserve_video_timestamps, + ), + ffmpeg=ffmpeg, + script=script, + audio_source=audio_source, + output=output, + options=effective_codec_options, + audio_options=effective_audio_options, + audio_start=timeline.frames[0].start, + audio_duration=timeline.duration_seconds, + audio_segments=audio_segments, + audio_tempo=audio_tempo, + audio_sample_rate=probe.audio_sample_rate, + source_has_audio=source_has_audio, + allow_audio_copy=effective_audio_options.mode == "copy", + pixel_format=pixel_format, + colorspace=colorspace, + frame_count=len(timeline.frames), + progress_label=( + f"script {item_index}/{len(inputs)}: {item.collapsed_relative}" + ), + ) + if preserve_video_timestamps: + assert mkvmerge is not None + result = encode_video_with_timestamps( + mkvmerge=mkvmerge, + x264=x264, + frame_durations=[frame.duration for frame in timeline.frames], + **encode_kwargs, + ) + else: + result = encode_video_only_y4m(**encode_kwargs) + validate_encoded_video( + ffprobe, + result.output, + timeline, + expected_duration=output_duration, + expected_timestamps=( + timeline_frame_starts(timeline) if preserve_video_timestamps else None + ), + expected_audio=( + effective_audio_options.mode != "none" and source_has_audio + ), + ) + print_encoder_statistics(result.encoder_log) + copy_video_metadata(exiftool, item.path, result.output) + write_video_end_timestamp(exiftool, result.output, probe, timeline, output_duration) + encoded_items.append(EncodedItem(input_item=item, output=result.output)) + print(f" encoded output: {result.output}") + + if not encoded_items: + print("\nNo outputs encoded.") + return + + finalize_encoded_outputs(root, encoded_items) + + +def ask_interactive_encoding_settings( + *, + inputs: list[VideoInput], + timelines: dict[Path, OutputTimeline], + clip_infos: dict[Path, AvisynthClipInfo], + probes_by_path: dict[Path, VideoProbe], +) -> EncodingSettings | None: + answers: dict[str, object] = { + "_timezone_default": _default_output_timezone( + [probes_by_path[item.path.resolve()] for item in inputs] + ) + } + index = 0 + has_speed_changes = has_avisynth_speed_changes(timelines, clip_infos) + specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path) + + def answer(key: str, value: object) -> None: + old_value = answers.get(key) + answers[key] = value + if key == "codec" and old_value is not None and old_value != value: + _forget(answers, {"crf", "profile", "level"}) + if key == "container" and old_value is not None and old_value != value: + _forget(answers, {"audio", "audio_bitrate"}) + if key == "timestamp_names" and value is False: + _forget(answers, {"timezone"}) + if key == "audio" and value not in {"aac", "opus"}: + _forget(answers, {"audio_bitrate", "audio_pitch"}) + + while True: + steps = _encoding_steps(answers, has_speed_changes) + index = max(0, min(index, len(steps) - 1)) + key = steps[index] + _render_encoding_screen(answers, key, specs, has_speed_changes) + raw = prompt_input(_question_for_key(key, answers, specs)).strip() + if raw.lower() in {"b", "back"}: + index = max(0, index - 1) + continue + + try: + value = _parse_answer(key, raw, answers, specs) + except ValueError as exc: + print(exc) + prompt_input("Press Enter to retry...") + continue + answer(key, value) + if key == "confirm" and value is False: + return None + if index == len(steps) - 1: + break + index += 1 + + codec = answers["codec"] + assert isinstance(codec, str) + audio_mode = answers.get("audio", "aac" if answers["container"] == ".mp4" else "opus") + assert isinstance(audio_mode, str) + return EncodingSettings( + naming=OutputNamingOptions( + use_timestamps=bool(answers["timestamp_names"]), + timezone_value=answers.get( + "timezone", + answers["_timezone_default"], + ), + ), + codec_options=VideoCodecOptions( + codec=codec, + crf=int(answers["crf"]), + preset=str(answers["preset"]), + extension=str(answers["container"]), + profile=_none_if_none(str(answers["profile"])), + level=_none_if_none(str(answers["level"])), + threads=_threads_from_answer(answers["threads"]), + ), + audio_options=AudioEncodeOptions( + mode=audio_mode, + bitrate=str(answers.get("audio_bitrate") or default_audio_bitrate(audio_mode)), + preserve_pitch=bool(answers.get("audio_pitch", True)), + ), + ) + + +def _encoding_env_is_set() -> bool: + names = { + "MBT_VIDEO_TIMESTAMP_NAMES", + "MBT_VIDEO_TIMEZONE", + "MBT_VIDEO_CONTAINER", + "MBT_VIDEO_CODEC", + "MBT_VIDEO_CRF", + "MBT_VIDEO_PRESET", + "MBT_VIDEO_PROFILE", + "MBT_VIDEO_LEVEL", + "MBT_VIDEO_THREADS", + "MBT_VIDEO_AUDIO", + "MBT_VIDEO_AUDIO_BITRATE", + "MBT_VIDEO_AUDIO_PITCH", + } + return any(os.environ.get(name) for name in names) + + +def _encoding_steps(answers: dict[str, object], has_speed_changes: bool) -> list[str]: + steps = ["timestamp_names"] + if answers.get("timestamp_names", True): + steps.append("timezone") + steps.extend(["container", "codec", "crf", "preset", "profile", "level", "threads", "audio"]) + if answers.get("audio") in {"aac", "opus"}: + steps.append("audio_bitrate") + if has_speed_changes and answers.get("audio") in {"aac", "opus"}: + steps.append("audio_pitch") + steps.append("confirm") + return steps + + +def _render_encoding_screen( + answers: dict[str, object], + current_key: str, + specs: list[EncodingOutputSpec], + has_speed_changes: bool, +) -> None: + clear_screen() + print("Validated clips:") + for spec in specs: + print(f"\n{spec.label}") + for line in spec.summary_lines: + print(f" {line}") + print("") + print("Encoding settings:") + rows = [ + ( + key, + _setting_label(key), + _setting_value(key, _default_answer(key, answers, specs), answers, specs), + ) + for key in _encoding_table_keys(answers, has_speed_changes) + ] + width = max(len(label) for _, label, _ in rows) + for key, label, value in rows: + marker = ">" if key == current_key else " " + print(f"{marker} {label.ljust(width)} {value}") + print("\nType b to go back.\n") + if current_key == "codec" and _x264_unavailable(specs): + print(light_red("x264 is unavailable because this batch contains output above 10-bit.\n")) + + +def _encoding_table_keys(answers: dict[str, object], has_speed_changes: bool) -> list[str]: + keys = [ + "timestamp_names", + "container", + "codec", + "crf", + "preset", + "profile", + "level", + "threads", + "audio", + ] + if _default_answer("timestamp_names", answers, []): + keys.insert(1, "timezone") + if _default_answer("audio", answers, []) in {"aac", "opus"}: + keys.append("audio_bitrate") + if has_speed_changes and _default_answer("audio", answers, []) in {"aac", "opus"}: + keys.append("audio_pitch") + return keys + + +def _question_for_key( + key: str, + answers: dict[str, object], + specs: list[EncodingOutputSpec], +) -> str: + if key == "timestamp_names": + default = bool(_default_answer(key, answers, specs)) + preview = _filename_preview(answers, specs, limit=1) + return ( + "Name encoded files from edited beginning timestamp? " + f"({preview}) ({_yes_no_suffix(default)}): " + ) + if key == "timezone": + default = _default_answer(key, answers, specs) + return f"Output filename timezone [{timezone_to_string(default)}]: " + if key == "container": + return ( + f"Output container [{_container_label(_default_answer(key, answers, specs))}] " + "(mp4/mkv): " + ) + if key == "codec": + options = ["x264", "x265"] + if _x264_unavailable(specs): + options[0] = red_strikethrough(options[0]) + return ( + f"Video codec [{_codec_label(_default_answer(key, answers, specs))}] " + f"({'/'.join(options)}): " + ) + if key == "crf": + return f"CRF [{_default_answer(key, answers, specs)}]: " + if key == "preset": + default = _default_answer(key, answers, specs) + return f"Encoder preset [{default}] ({'/'.join(ENCODER_PRESETS)}): " + if key == "profile": + codec = str(answers["codec"]) + options = _profile_options(codec, specs) + unsupported = _not_supported_by_all(codec, options, specs, profile=True) + return "Profile " + _options_prompt( + options, + str(_default_answer(key, answers, specs)), + unsupported, + ) + ": " + if key == "level": + codec = str(answers["codec"]) + options = _level_options(codec, specs) + unsupported = _not_supported_by_all(codec, options, specs, profile=False) + return "Level " + _options_prompt( + options, + str(_default_answer(key, answers, specs)), + unsupported, + ) + ": " + if key == "threads": + return f"Encoder threads [{_default_answer(key, answers, specs)}] (auto or positive integer): " + if key == "audio": + if answers.get("container") == ".mkv": + return f"Audio [{_default_answer(key, answers, specs)}] (opus/aac/flac/copy/none): " + return f"Audio [{_default_answer(key, answers, specs)}] (aac/copy/none): " + if key == "audio_bitrate": + return f"{str(answers['audio']).upper()} audio bitrate [{_default_answer(key, answers, specs)}]: " + if key == "audio_pitch": + return "Speed-changed audio pitch [preserve] (preserve/shift): " + if key == "confirm": + return "Start encoding with these settings? (Y/n): " + raise ValueError(f"Unknown setting: {key}") + + +def _parse_answer( + key: str, + raw: str, + answers: dict[str, object], + specs: list[EncodingOutputSpec], +) -> object: + default = _default_answer(key, answers, specs) + value = raw or str(default) + lowered = value.strip().lower() + if key == "confirm": + return lowered not in {"n", "no", "0", "false", "off"} + if key == "timestamp_names": + return lowered not in {"n", "no", "0", "false", "off"} + if key == "timezone": + return parse_timezone_offset(value) if raw else default + if key == "container": + if lowered in {"mp4", ".mp4"}: + return ".mp4" + if lowered in {"m", "mkv", ".mkv"}: + return ".mkv" + raise ValueError("Container must be mp4 or mkv.") + if key == "codec": + if lowered in {"x264", "h264", "libx264"}: + if _x264_unavailable(specs): + raise ValueError( + "x264 is unavailable because at least one validated output is above 10-bit." + ) + return "libx264" + if lowered in {"x265", "h265", "hevc", "libx265"}: + return "libx265" + raise ValueError("Codec must be x264 or x265.") + if key == "crf": + try: + return int(value) + except ValueError as exc: + raise ValueError("CRF must be an integer.") from exc + if key == "preset": + if lowered not in ENCODER_PRESETS: + raise ValueError(f"Preset must be one of: {'/'.join(ENCODER_PRESETS)}.") + return lowered + if key == "profile": + options = _profile_options(str(answers["codec"]), specs) + return _parse_option("profile", value, options) + if key == "level": + options = _level_options(str(answers["codec"]), specs) + return _parse_option("level", value, options) + if key == "threads": + if lowered == "auto": + return "auto" + try: + threads = int(value) + except ValueError as exc: + raise ValueError("Threads must be auto or a positive integer.") from exc + if threads < 1: + raise ValueError("Threads must be auto or a positive integer.") + return threads + if key == "audio": + return _parse_audio(value, str(answers["container"])) + if key == "audio_bitrate": + return value + if key == "audio_pitch": + if lowered in {"preserve", "p", "keep", "same"}: + return True + if lowered in {"shift", "s", "change", "speed"}: + return False + raise ValueError("Audio pitch must be preserve or shift.") + raise ValueError(f"Unknown setting: {key}") + + +def _default_answer( + key: str, + answers: dict[str, object], + specs: list[EncodingOutputSpec], +) -> object: + if key in answers: + return answers[key] + codec = str(answers.get("codec", "libx265")) + if key == "confirm": + return True + if key == "timestamp_names": + return True + if key == "timezone": + return answers.get("_timezone_default", local_timezone()) + if key == "container": + return ".mp4" + if key == "codec": + return "libx265" + if key == "crf": + return 16 if codec == "libx264" else 21 + if key == "preset": + return "slow" + if key == "profile": + return "minimum" + if key == "level": + return "minimum" + if key == "threads": + return "auto" + if key == "audio": + return "aac" if answers.get("container", ".mp4") == ".mp4" else "opus" + if key == "audio_bitrate": + return default_audio_bitrate(str(answers.get("audio", "aac"))) + if key == "audio_pitch": + return "preserve" + raise ValueError(f"Unknown setting: {key}") + + +def _encoding_specs( + inputs: list[VideoInput], + timelines: dict[Path, OutputTimeline], + clip_infos: dict[Path, AvisynthClipInfo], + probes_by_path: dict[Path, VideoProbe], +) -> list[EncodingOutputSpec]: + specs: list[EncodingOutputSpec] = [] + for item in inputs: + timeline = timelines[item.path.resolve()] + clip_info = clip_infos.get(item.path.resolve()) + probe = probes_by_path[item.path.resolve()] + width = clip_info.width if clip_info else (probe.width or 0) + height = clip_info.height if clip_info else (probe.height or 0) + bit_depth = clip_info.bit_depth if clip_info else (probe.bit_depth or 8) + chroma = chroma_family(clip_info.chroma if clip_info else probe.pixel_format) + fps = len(timeline.frames) / timeline.duration_seconds if timeline.duration_seconds > 0 else 0 + specs.append( + EncodingOutputSpec( + label=str(item.collapsed_relative), + width=width, + height=height, + bit_depth=bit_depth, + chroma=chroma, + fps=fps, + begin_utc=_begin_utc(probe, timeline), + summary_lines=tuple( + format_validation_summary_lines( + output_frame_count=( + clip_info.frames + if clip_info is not None + else len(timeline.frames) + timeline.dropped_frame_count + ), + dropped_frame_count=timeline.dropped_frame_count, + timeline=timeline, + clip_info=clip_info, + probe=probe, + ) + ), + ) + ) + return specs + + +def _profile_options(codec: str, specs: list[EncodingOutputSpec]) -> list[str]: + constraints = [_constraint_spec(spec) for spec in specs] + return ["none", "minimum", *available_profiles(codec, constraints)] + + +def _level_options(codec: str, specs: list[EncodingOutputSpec]) -> list[str]: + constraints = [_constraint_spec(spec) for spec in specs] + return ["none", "minimum", *available_levels(codec, constraints)] + + +def _not_supported_by_all( + codec: str, + options: list[str], + specs: list[EncodingOutputSpec], + *, + profile: bool, +) -> set[str]: + support = profile_supports if profile else level_supports + constraints = [_constraint_spec(spec) for spec in specs] + return { + option + for option in options + if option not in {"none", "minimum"} + and not all(support(codec, option, spec) for spec in constraints) + } + + +def _constraint_spec(spec: EncodingOutputSpec) -> VideoConstraintSpec: + return VideoConstraintSpec( + width=spec.width, + height=spec.height, + bit_depth=spec.bit_depth, + chroma=spec.chroma, + fps=spec.fps, + ) + + +def _constraint_spec_for_item( + item: VideoInput, + timelines: dict[Path, OutputTimeline], + clip_infos: dict[Path, AvisynthClipInfo], + probes_by_path: dict[Path, VideoProbe], +) -> VideoConstraintSpec: + return _constraint_spec( + _encoding_specs([item], timelines, clip_infos, probes_by_path)[0] + ) + + +def _resolve_codec_options( + options: VideoCodecOptions, + spec: VideoConstraintSpec, +) -> VideoCodecOptions: + return replace( + options, + profile=resolve_profile(options.codec, options.profile, spec), + level=resolve_level(options.codec, options.level, spec), + ) + + +def _x264_unavailable(specs: list[EncodingOutputSpec]) -> bool: + return any(spec.bit_depth > 10 for spec in specs) + + +def _begin_utc(probe: VideoProbe, timeline: OutputTimeline) -> str: + try: + return output_begin_timestamp(probe, timeline, timezone.utc).isoformat() + except RuntimeError: + return "unknown" + + +def _parse_option(name: str, value: str, options: list[str]) -> str: + lowered = value.strip().lower() + matches = [option for option in options if option.lower() == lowered] + if matches: + return matches[0] + raise ValueError(f"{name.title()} must be one of: {', '.join(options)}") + + +def _parse_audio(value: str, container: str) -> str: + lowered = value.strip().lower() + if lowered in {"aac", "a"}: + return "aac" + if lowered in {"copy", "c"}: + return "copy" + if lowered in {"none", "no", "n"}: + return "none" + if container == ".mkv" and lowered in {"opus", "o"}: + return "opus" + if container == ".mkv" and lowered in {"flac", "f"}: + return "flac" + raise ValueError("Unsupported audio choice for this container.") + + +def _yes_no_suffix(default: bool) -> str: + return "Y/n" if default else "y/N" + + +def _forget(answers: dict[str, object], keys: set[str]) -> None: + for key in keys: + answers.pop(key, None) + + +def _setting_label(key: str) -> str: + return { + "timestamp_names": "timestamp names", + "timezone": "timezone", + "container": "container", + "codec": "codec", + "crf": "CRF", + "preset": "preset", + "profile": "profile", + "level": "level", + "threads": "threads", + "audio": "audio", + "audio_bitrate": "audio bitrate", + "audio_pitch": "audio pitch", + }.get(key, key) + + +def _setting_value( + key: str, + value: object, + answers: dict[str, object], + specs: list[EncodingOutputSpec], +) -> str: + if key == "timestamp_names": + if not value: + return "no (original filename)" + return f"yes ({_filename_preview(answers, specs, limit=3)})" + if key == "timezone": + return timezone_to_string(value) + if key == "container": + return _container_label(value) + if key == "codec": + return _codec_label(value) + if key in {"profile", "level"} and value not in {"none", "minimum"}: + codec = str(answers.get("codec", "libx265")) + unsupported = _not_supported_by_all( + codec, + [str(value)], + specs, + profile=key == "profile", + ) + return light_red(value) if unsupported else str(value) + if key == "audio_pitch": + return "preserve" if value else "shift" + return str(value) + + +def _filename_preview( + answers: dict[str, object], + specs: list[EncodingOutputSpec], + *, + limit: int, +) -> str: + if not specs: + return "unknown" + timezone_value = _default_answer("timezone", answers, specs) + assert hasattr(timezone_value, "utcoffset") + stems = [ + _filename_preview_for_timezone(spec, timezone_value) + for spec in specs[:limit] + ] + if len(specs) > limit: + stems.append("...") + return ", ".join(stems) + + +def _filename_preview_for_timezone( + spec: EncodingOutputSpec, + timezone_value: timezone, +) -> str: + begin = _parse_iso_datetime(spec.begin_utc) + if begin is None: + return "unknown" + return format_rounded_filename_stem(begin.astimezone(timezone_value)) + + +def _parse_iso_datetime(value: str) -> datetime | None: + if value == "unknown": + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _container_label(value: object) -> str: + return "MP4" if value == ".mp4" else "MKV" + + +def _default_output_timezone(probes: list[VideoProbe]) -> timezone: + offsets = { + probe.source_timezone.utcoffset(None) + for probe in probes + if probe.source_timezone is not None + } + if len(offsets) == 1: + offset = offsets.pop() + if offset is not None: + return timezone(offset) + return local_timezone() + + +def _codec_label(value: object) -> str: + return "x264" if value == "libx264" else "x265" + + +def _options_prompt( + options: list[str], + default: str, + unsupported: set[str] | None = None, +) -> str: + unsupported = unsupported or set() + displayed = [light_red(option) if option in unsupported else option for option in options] + return f"[{default}] ({'/'.join(displayed)})" + + +def _none_if_none(value: str) -> str | None: + return None if value == "none" else value + + +def _threads_from_answer(value: object) -> int | None: + return None if str(value).strip().lower() == "auto" else int(value) + + +def prepare_audio_source( + *, + avs_runners: AvisynthRunnerSet, + root: Path, + item: VideoInput, + original_has_audio: bool, + audio_options: AudioEncodeOptions, +) -> tuple[Path, bool]: + if audio_options.mode == "none": + return item.path, False + + audio_script = editable_audio_script_path(root, item) + if not audio_script.exists(): + return item.path, original_has_audio + + avs_runner = avs_runners.for_script(audio_script) + wav_path = rendered_audio_wav_path(root, item) + print(f" audio: rendering {audio_script.name}") + render_audio_wav(avs_runner=avs_runner, script=audio_script, output=wav_path) + return wav_path, True + + +def _format_audio_options(options: AudioEncodeOptions) -> str: + if options.mode == "none": + return "none" + if options.mode == "aac": + pitch = "preserve pitch" if options.preserve_pitch else "shift pitch with speed" + return f"AAC {options.bitrate}, {pitch}" + if options.mode == "opus": + pitch = "preserve pitch" if options.preserve_pitch else "shift pitch with speed" + return f"Opus {options.bitrate}, {pitch}" + if options.mode == "flac": + return "FLAC" + if options.mode == "copy": + return "copy with best-effort beginning/end trim" + return options.mode diff --git a/tools/video_codec_constraints.py b/tools/video_codec_constraints.py new file mode 100644 index 0000000..81fabdc --- /dev/null +++ b/tools/video_codec_constraints.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import sqrt + + +@dataclass(frozen=True) +class VideoConstraintSpec: + width: int + height: int + bit_depth: int + chroma: str + fps: float + + +_CHROMA_RANK = {"420": 0, "422": 1, "444": 2} + +_X264_PROFILES = ( + ("baseline", 8, "420"), + ("main", 8, "420"), + ("high", 8, "420"), + ("high10", 10, "420"), + ("high422", 10, "422"), + ("high444", 10, "444"), +) + +_X265_PROFILES = ( + ("main", 8, "420"), + ("main10", 10, "420"), + ("main12", 12, "420"), + ("main422-10", 10, "422"), + ("main422-12", 12, "422"), + ("main444-8", 8, "444"), + ("main444-10", 10, "444"), + ("main444-12", 12, "444"), +) + +# (name, maximum luma samples per picture, maximum luma samples per second) +_X265_LEVELS = ( + ("1.0", 36_864, 552_960), + ("2.0", 122_880, 3_686_400), + ("2.1", 245_760, 7_372_800), + ("3.0", 552_960, 16_588_800), + ("3.1", 983_040, 33_177_600), + ("4.0", 2_228_224, 66_846_720), + ("4.1", 2_228_224, 133_693_440), + ("5.0", 8_912_896, 267_386_880), + ("5.1", 8_912_896, 534_773_760), + ("5.2", 8_912_896, 1_069_547_520), + ("6.0", 35_651_584, 1_069_547_520), + ("6.1", 35_651_584, 2_139_095_040), + ("6.2", 35_651_584, 4_278_190_080), +) + +# (name, maximum macroblocks per frame, maximum macroblocks per second) +_X264_LEVELS = ( + ("1.0", 99, 1_485), + ("1.1", 396, 3_000), + ("1.2", 396, 6_000), + ("1.3", 396, 11_880), + ("2.0", 396, 11_880), + ("2.1", 792, 19_800), + ("2.2", 1_620, 20_250), + ("3.0", 1_620, 40_500), + ("3.1", 3_600, 108_000), + ("3.2", 5_120, 216_000), + ("4.0", 8_192, 245_760), + ("4.1", 8_192, 245_760), + ("4.2", 8_704, 522_240), + ("5.0", 22_080, 589_824), + ("5.1", 36_864, 983_040), + ("5.2", 36_864, 2_073_600), + ("6.0", 139_264, 4_177_920), + ("6.1", 139_264, 8_355_840), + ("6.2", 139_264, 16_711_680), +) + + +def profile_names(codec: str) -> list[str]: + return [name for name, _, _ in _profiles(codec)] + + +def profile_supports(codec: str, profile: str, spec: VideoConstraintSpec) -> bool: + _, depth, chroma = _profile(codec, profile) + return ( + depth >= spec.bit_depth + and _CHROMA_RANK[chroma] >= _CHROMA_RANK[spec.chroma] + ) + + +def minimum_profile(codec: str, spec: VideoConstraintSpec) -> str: + for name in profile_names(codec): + if profile_supports(codec, name, spec): + return name + raise ValueError( + f"{codec} has no profile for " + f"{spec.bit_depth}-bit 4:{spec.chroma[1]}:{spec.chroma[2]}" + ) + + +def resolve_profile(codec: str, choice: str | None, spec: VideoConstraintSpec) -> str | None: + if choice is None or choice == "none": + return None + if choice == "minimum": + return minimum_profile(codec, spec) + + if codec == "libx264": + names = profile_names(codec) + start = names.index(choice) + for name in names[start:]: + if profile_supports(codec, name, spec): + return name + return minimum_profile(codec, spec) + + _, requested_depth, requested_chroma = _profile(codec, choice) + required_depth = max(requested_depth, spec.bit_depth) + required_chroma = max( + _CHROMA_RANK[requested_chroma], + _CHROMA_RANK[spec.chroma], + ) + candidates = [ + (name, depth, chroma) + for name, depth, chroma in _profiles(codec) + if depth >= required_depth and _CHROMA_RANK[chroma] >= required_chroma + ] + if not candidates: + return minimum_profile(codec, spec) + return min( + candidates, + key=lambda item: (item[1], _CHROMA_RANK[item[2]]), + )[0] + + +def available_profiles(codec: str, specs: list[VideoConstraintSpec]) -> list[str]: + native_capabilities = { + _profile(codec, minimum_profile(codec, spec))[1:] + for spec in specs + } + return [ + name + for name, depth, chroma in _profiles(codec) + if (depth, chroma) in native_capabilities + ] + + +def level_names(codec: str) -> list[str]: + return [name for name, _, _ in _levels(codec)] + + +def level_supports(codec: str, level: str, spec: VideoConstraintSpec) -> bool: + _, max_frame, max_rate = _level(codec, level) + if codec == "libx264": + width = (spec.width + 15) // 16 + height = (spec.height + 15) // 16 + else: + width = spec.width + height = spec.height + frame_size = width * height + max_dimension = sqrt(max_frame * 8) + return ( + frame_size <= max_frame + and width <= max_dimension + and height <= max_dimension + and frame_size * spec.fps <= max_rate + ) + + +def minimum_level(codec: str, spec: VideoConstraintSpec) -> str: + for name in level_names(codec): + if level_supports(codec, name, spec): + return name + raise ValueError( + f"{codec} has no supported level for {spec.width}x{spec.height} at {spec.fps:.3f} fps" + ) + + +def resolve_level(codec: str, choice: str | None, spec: VideoConstraintSpec) -> str | None: + if choice is None or choice == "none": + return None + minimum = minimum_level(codec, spec) + if choice == "minimum": + return minimum + names = level_names(codec) + return names[max(names.index(choice), names.index(minimum))] + + +def available_levels(codec: str, specs: list[VideoConstraintSpec]) -> list[str]: + return [ + name + for name in level_names(codec) + if any(level_supports(codec, name, spec) for spec in specs) + ] + + +def _profiles(codec: str): + if codec == "libx264": + return _X264_PROFILES + if codec == "libx265": + return _X265_PROFILES + raise ValueError(f"Unsupported codec: {codec}") + + +def _profile(codec: str, name: str): + for profile in _profiles(codec): + if profile[0] == name: + return profile + raise ValueError(f"Unknown {codec} profile: {name}") + + +def _levels(codec: str): + if codec == "libx264": + return _X264_LEVELS + if codec == "libx265": + return _X265_LEVELS + raise ValueError(f"Unsupported codec: {codec}") + + +def _level(codec: str, name: str): + for level in _levels(codec): + if level[0] == name: + return level + raise ValueError(f"Unknown {codec} level: {name}") diff --git a/tools/video_encode_output.py b/tools/video_encode_output.py new file mode 100644 index 0000000..2413285 --- /dev/null +++ b/tools/video_encode_output.py @@ -0,0 +1,641 @@ +from __future__ import annotations + +import subprocess +import sys +import re +from contextlib import contextmanager +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from threading import Thread +from typing import Callable + +from tools.console import PipelineProgressView, ProgressView +from tools.video_formatting import chroma_family + + +ENCODER_PRESETS = ( + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", + "placebo", +) + + +@dataclass(frozen=True) +class VideoCodecOptions: + codec: str + crf: int + preset: str + extension: str = ".mp4" + profile: str | None = None + level: str | None = None + threads: int | None = None + + +@dataclass(frozen=True) +class AudioEncodeOptions: + mode: str + bitrate: str = "192k" + preserve_pitch: bool = True + + +def default_audio_bitrate(mode: str) -> str: + return {"aac": "192k", "opus": "128k"}.get(mode, "") + + +@dataclass(frozen=True) +class VideoEncodeResult: + script: Path + output: Path + renderer_returncode: int + ffmpeg_returncode: int + encoder_log: str = "" + + +@dataclass(frozen=True) +class Y4MEncodeRun: + renderer_returncode: int + encoder_returncode: int + encoder_log: str + renderer_log: str + + +@dataclass +class RendererStderrReader: + process: subprocess.Popen + progress: PipelineProgressView | None + lines: list[str] + thread: Thread + + def finish(self) -> str: + self.thread.join() + if self.progress is not None: + self.progress.finish() + return "".join(self.lines) + + +def choose_video_pixel_format( + ffmpeg: Path, + codec: str, + bit_depth: int, + chroma: str | None = None, +) -> str: + family = chroma_family(chroma) + supported = supported_pixel_formats(ffmpeg, codec, family) + preserving_formats = [ + (depth, pix_fmt) + for depth, pix_fmt in supported + if depth >= bit_depth + ] + if preserving_formats: + return min(preserving_formats, key=lambda item: item[0])[1] + raise RuntimeError( + f"{codec} does not report a yuv{family} pixel format that preserves " + f"the {bit_depth}-bit Avisynth output" + ) + + +def pixel_format_bit_depth(pixel_format: str) -> int: + for depth in (16, 14, 12, 10): + if f"p{depth}" in pixel_format: + return depth + return 8 + + +def supported_pixel_formats(ffmpeg: Path, codec: str, family: str) -> list[tuple[int, str]]: + completed = subprocess.run( + [str(ffmpeg), "-hide_banner", "-h", f"encoder={codec}"], + capture_output=True, + text=True, + ) + if completed.returncode != 0: + return [(8, "yuv420p")] + + supported: list[tuple[int, str]] = [] + for line in completed.stdout.splitlines() + completed.stderr.splitlines(): + if "Supported pixel formats:" not in line: + continue + formats = line.split(":", 1)[1].split() + base = f"yuv{family}p" + if base in formats: + supported.append((8, base)) + for depth in (10, 12, 14, 16): + pix_fmt = f"{base}{depth}le" + if pix_fmt in formats: + supported.append((depth, pix_fmt)) + break + if supported: + return supported + return [(8, f"yuv{family}p")] if family == "420" else [] + + +def ffmpeg_colorspace_from_matrix(matrix: int | None) -> str | None: + if matrix == 1: + return "bt709" + if matrix in {5, 6}: + return "smpte170m" + if matrix in {9, 10}: + return "bt2020nc" + return None + + +def render_audio_wav( + *, + avs_runner: Path, + script: Path, + output: Path, +) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + with output.open("wb") as wav_file: + completed = subprocess.run( + [str(avs_runner), "--wav", str(script)], + stdout=wav_file, + stderr=subprocess.PIPE, + text=False, + ) + if completed.returncode != 0: + raise RuntimeError( + f"Avisynth runner failed to render audio for {script} with exit " + f"{completed.returncode}:\n" + + completed.stderr.decode(errors="replace") + ) + + +def encode_video_only_y4m( + *, + y4m_command: list[str], + ffmpeg: Path, + script: Path, + audio_source: Path | None, + output: Path, + options: VideoCodecOptions | None = None, + audio_options: AudioEncodeOptions | None = None, + audio_start: float = 0.0, + audio_duration: float | None = None, + audio_segments: list[tuple[float, float]] | None = None, + audio_tempo: float = 1.0, + audio_sample_rate: int | None = None, + source_has_audio: bool = False, + allow_audio_copy: bool = False, + pixel_format: str = "yuv420p", + colorspace: str | None = None, + frame_count: int | None = None, + progress_label: str | None = None, +) -> VideoEncodeResult: + if options is None: + options = VideoCodecOptions(codec="libx264", crf=16, preset="slow") + if audio_options is None: + audio_options = AudioEncodeOptions(mode="none") + + output.parent.mkdir(parents=True, exist_ok=True) + command = ffmpeg_y4m_command( + ffmpeg=ffmpeg, + audio_source=audio_source, + output=output, + options=options, + audio_options=audio_options, + audio_start=audio_start, + audio_duration=audio_duration, + audio_segments=audio_segments, + audio_tempo=audio_tempo, + audio_sample_rate=audio_sample_rate, + source_has_audio=source_has_audio, + allow_audio_copy=allow_audio_copy, + pixel_format=pixel_format, + colorspace=colorspace, + ) + run = run_y4m_encoder( + y4m_command=y4m_command, + script=script, + encoder_command=command, + frame_count=frame_count, + progress_label=progress_label or output.name, + read_progress=read_ffmpeg_progress, + ) + if run.encoder_returncode != 0: + raise RuntimeError( + f"FFmpeg failed for {script} with exit {run.encoder_returncode}:\n" + + run.encoder_log + + ( + "\nAvisynth runner also exited with " + f"{run.renderer_returncode}:\n" + + run.renderer_log + if run.renderer_returncode != 0 + else "" + ) + ) + if run.renderer_returncode != 0: + raise RuntimeError( + f"Avisynth runner failed for {script} with exit {run.renderer_returncode}:\n" + + run.renderer_log + ) + + return VideoEncodeResult( + script=script, + output=output, + renderer_returncode=run.renderer_returncode, + ffmpeg_returncode=run.encoder_returncode, + encoder_log=run.encoder_log, + ) + + +def ffmpeg_y4m_command( + *, + ffmpeg: Path, + audio_source: Path | None, + output: Path, + options: VideoCodecOptions, + audio_options: AudioEncodeOptions, + audio_start: float, + audio_duration: float | None, + audio_segments: list[tuple[float, float]] | None, + audio_tempo: float, + audio_sample_rate: int | None, + source_has_audio: bool, + allow_audio_copy: bool, + pixel_format: str, + colorspace: str | None, +) -> list[str]: + command = [ + str(ffmpeg), "-y", "-hide_banner", "-loglevel", "info", "-nostats", + "-progress", "pipe:2", "-f", "yuv4mpegpipe", "-i", "pipe:0", + ] + if audio_options.mode != "none" and source_has_audio: + if audio_source is None: + raise RuntimeError("An audio source is required when audio encoding is enabled") + add_audio_input_options( + command, + audio_options=audio_options, + audio_start=audio_start, + audio_duration=audio_duration, + allow_audio_copy=allow_audio_copy, + ) + command.extend(["-i", str(audio_source)]) + command.extend([ + "-map", "0:v:0", "-c:v", options.codec, "-preset", options.preset, + "-crf", str(options.crf), "-pix_fmt", pixel_format, + ]) + add_video_profile_level(command, options) + add_video_threads(command, options) + if colorspace is not None: + command.extend(["-colorspace", colorspace]) + add_audio_options( + command, + audio_options=audio_options, + audio_start=audio_start, + audio_duration=audio_duration, + audio_segments=audio_segments, + audio_tempo=audio_tempo, + audio_sample_rate=audio_sample_rate, + source_has_audio=source_has_audio, + allow_audio_copy=allow_audio_copy, + ) + command.append(str(output)) + return command + + +def run_y4m_encoder( + *, + y4m_command: list[str], + script: Path, + encoder_command: list[str], + frame_count: int | None, + progress_label: str, + read_progress: Callable[..., str], +) -> Y4MEncodeRun: + with video_only_script(script) as render_script: + renderer_process = subprocess.Popen( + [*y4m_command, str(render_script)], stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + assert renderer_process.stdout is not None + renderer_reader = start_renderer_stderr_reader( + renderer_process, + rendering_label=f"Rendering {progress_label}", + encoding_label=f"Encoding {progress_label}", + encoding_total=frame_count, + ) + encoder_process = subprocess.Popen( + encoder_command, + stdin=renderer_process.stdout, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + renderer_process.stdout.close() + encoder_log = read_progress( + encoder_process, + total_frames=frame_count, + label=f"Encoding {progress_label}", + pipeline_progress=renderer_reader.progress, + ) + renderer_log = renderer_reader.finish() + renderer_returncode = renderer_process.wait() + return Y4MEncodeRun( + renderer_returncode=renderer_returncode, + encoder_returncode=encoder_process.returncode, + encoder_log=encoder_log, + renderer_log=renderer_log, + ) + + +@contextmanager +def video_only_script(script: Path) -> Iterator[Path]: + wrapper = script.with_name(f".{script.name}.video-only.avs") + wrapper.write_text( + 'mbt_video_only = true\n' + f'Import("{_avs_path(script.name)}")\n' + "last\n", + encoding="utf-8", + ) + try: + yield wrapper + finally: + wrapper.unlink(missing_ok=True) + + +def _avs_path(path: str) -> str: + return path.replace("\\", "/").replace('"', '\\"') + + +def add_audio_input_options( + command: list[str], + *, + audio_options: AudioEncodeOptions, + audio_start: float, + audio_duration: float | None, + allow_audio_copy: bool, +) -> None: + if audio_options.mode != "copy": + return + if not allow_audio_copy: + return + if audio_start > 0.001: + command.extend(["-ss", f"{audio_start:.6f}"]) + if audio_duration is not None: + command.extend(["-t", f"{audio_duration:.6f}"]) + + +def add_audio_options( + command: list[str], + *, + audio_options: AudioEncodeOptions, + audio_start: float, + audio_duration: float | None, + audio_segments: list[tuple[float, float]] | None = None, + audio_tempo: float, + audio_sample_rate: int | None, + source_has_audio: bool, + allow_audio_copy: bool, +) -> None: + if audio_options.mode == "none" or not source_has_audio: + command.append("-an") + elif audio_options.mode in {"aac", "opus", "flac"}: + if audio_segments is not None: + add_segmented_encoded_audio( + command, + audio_segments=audio_segments, + audio_options=audio_options, + audio_tempo=audio_tempo, + audio_sample_rate=audio_sample_rate, + ) + return + if audio_duration is None: + raise RuntimeError("audio_duration is required for AAC audio") + audio_filters = [ + f"atrim=start={audio_start:.6f}:duration={audio_duration:.6f}", + "asetpts=PTS-STARTPTS", + ] + audio_filters.extend( + speed_change_filters(audio_tempo, audio_options, audio_sample_rate) + ) + command.extend(["-map", "1:a:0", "-af", ",".join(audio_filters)]) + add_audio_encoder_options(command, audio_options) + elif audio_options.mode == "copy": + if not allow_audio_copy: + raise RuntimeError("Audio copy is only supported for whole-file or simple beginning/end trims.") + command.extend(["-map", "1:a:0", "-c:a", "copy"]) + else: + raise RuntimeError(f"Unsupported audio mode: {audio_options.mode}") + + +def add_video_profile_level(command: list[str], options: VideoCodecOptions) -> None: + if options.profile: + command.extend(["-profile:v", options.profile]) + if options.level: + command.extend(["-level:v", options.level]) + + +def add_video_threads(command: list[str], options: VideoCodecOptions) -> None: + if options.threads is None: + return + if options.codec == "libx265": + command.extend(["-x265-params", f"pools={options.threads}"]) + else: + command.extend(["-threads", str(options.threads)]) + + +def print_encoder_statistics(encoder_log: str) -> None: + statistics = encoder_statistics_lines(encoder_log) + if not statistics: + return + print(" encoder stats:") + for line in statistics: + print(f" {line}") + + +def encoder_statistics_lines(encoder_log: str) -> list[str]: + pattern = re.compile( + r"(frame [IPB]:.*|consecutive B-frames:.*|ref [PB] L[01]:.*)", + re.IGNORECASE, + ) + statistics: list[str] = [] + for line in encoder_log.splitlines(): + match = pattern.search(line) + if match: + statistics.append(match.group(1)) + return statistics + + +def start_renderer_stderr_reader( + process: subprocess.Popen, + *, + rendering_label: str, + encoding_label: str, + encoding_total: int | None, +) -> RendererStderrReader: + assert process.stderr is not None + progress = ( + PipelineProgressView( + rendering_label=rendering_label, + encoding_label=encoding_label, + encoding_total=encoding_total, + ) + if encoding_total and sys.stdout.isatty() + else None + ) + lines: list[str] = [] + + def consume() -> None: + for raw_line in process.stderr: + line = raw_line.decode(errors="replace") + if progress is not None and line.startswith("mbt_render,"): + if _update_renderer_progress(progress, line): + continue + lines.append(line) + + thread = Thread(target=consume, daemon=True) + thread.start() + return RendererStderrReader(process, progress, lines, thread) + + +def _update_renderer_progress(progress: PipelineProgressView, line: str) -> bool: + parts = line.strip().split(",") + if len(parts) != 6: + return False + try: + processed = int(parts[1]) + total = int(parts[2]) + elapsed = float(parts[4]) + eta = float(parts[5]) + except ValueError: + return False + progress.update_rendering(processed, total, elapsed=elapsed, eta=eta) + return True + + +def add_segmented_encoded_audio( + command: list[str], + *, + audio_segments: list[tuple[float, float]], + audio_options: AudioEncodeOptions, + audio_tempo: float, + audio_sample_rate: int | None, +) -> None: + if not audio_segments: + raise RuntimeError("audio_segments must not be empty") + + filters: list[str] = [] + labels: list[str] = [] + for index, (start, duration) in enumerate(audio_segments): + label = f"a{index}" + filters.append( + f"[1:a]atrim=start={start:.6f}:duration={duration:.6f}," + f"asetpts=PTS-STARTPTS[{label}]" + ) + labels.append(f"[{label}]") + + if len(labels) == 1: + current_label = labels[0] + else: + filters.append( + "".join(labels) + f"concat=n={len(labels)}:v=0:a=1[acut]" + ) + current_label = "[acut]" + + speed_filters = speed_change_filters(audio_tempo, audio_options, audio_sample_rate) + if speed_filters: + filters.append(f"{current_label}{','.join(speed_filters)}[aout]") + elif current_label != "[aout]": + filters.append(f"{current_label}anull[aout]") + + command.extend(["-filter_complex", ";".join(filters), "-map", "[aout]"]) + add_audio_encoder_options(command, audio_options) + + +def add_audio_encoder_options( + command: list[str], + audio_options: AudioEncodeOptions, +) -> None: + if audio_options.mode == "aac": + command.extend(["-c:a", "aac", "-b:a", audio_options.bitrate]) + elif audio_options.mode == "opus": + command.extend(["-c:a", "libopus", "-b:a", audio_options.bitrate]) + elif audio_options.mode == "flac": + command.extend(["-c:a", "flac"]) + else: + raise RuntimeError(f"Unsupported encoded audio mode: {audio_options.mode}") + + +def atempo_filters(tempo: float) -> list[str]: + if tempo <= 0: + raise RuntimeError(f"Invalid audio tempo factor: {tempo}") + if abs(tempo - 1.0) < 0.0001: + return [] + + filters: list[str] = [] + remaining = tempo + while remaining < 0.5: + filters.append("atempo=0.5") + remaining /= 0.5 + while remaining > 2.0: + filters.append("atempo=2.0") + remaining /= 2.0 + filters.append(f"atempo={remaining:.8f}") + return filters + + +def speed_change_filters( + tempo: float, + audio_options: AudioEncodeOptions, + audio_sample_rate: int | None, +) -> list[str]: + if abs(tempo - 1.0) < 0.0001: + return [] + if audio_options.preserve_pitch: + return atempo_filters(tempo) + if audio_sample_rate is None: + raise RuntimeError( + "Audio sample rate is required for speed changes without pitch preservation" + ) + changed_rate = max(1, round(audio_sample_rate * tempo)) + return [f"asetrate={changed_rate}", f"aresample={audio_sample_rate}"] + + +def read_ffmpeg_progress( + process: subprocess.Popen, + *, + total_frames: int | None, + label: str, + pipeline_progress: PipelineProgressView | None = None, +) -> str: + assert process.stderr is not None + errors: list[str] = [] + progress = ( + ProgressView( + total_frames or 0, + label, + embedded_percent=True, + show_rate=True, + ) + if total_frames and sys.stdout.isatty() and pipeline_progress is None + else None + ) + current_frame = 0 + if progress is not None: + progress.update(0) + for line in process.stderr: + text = line.rstrip("\r\n") + if text.startswith("frame="): + try: + current_frame = int(text.split("=", 1)[1].strip()) + except ValueError: + continue + if progress is not None: + progress.update(current_frame) + elif pipeline_progress is not None: + pipeline_progress.update_encoding(current_frame) + elif text.startswith(("fps=", "stream_", "bitrate=", "total_size=", "out_time_", "dup_frames=", "drop_frames=", "speed=", "progress=")): + continue + elif text: + errors.append(text) + process.wait() + if progress is not None: + progress.finish(keep=True) + return "\n".join(errors) diff --git a/tools/video_encode_plan.py b/tools/video_encode_plan.py new file mode 100644 index 0000000..8570a5b --- /dev/null +++ b/tools/video_encode_plan.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from fractions import Fraction +from pathlib import Path + +from tools.avisynth_render import AvisynthClipInfo +from tools.video_encode_output import AudioEncodeOptions +from tools.video_probe import VideoProbe +from tools.video_timeline import OutputTimeline + + +def normal_deleted_frame_count(timeline: OutputTimeline) -> int: + return missing_source_frames(timeline.consumed_source_frames) + + +def missing_source_frames_between_kept(timeline: OutputTimeline) -> int: + return missing_source_frames([frame.source_frame for frame in timeline.frames]) + + +def missing_source_frames(source_frames: list[int]) -> int: + missing = 0 + for previous, current in zip(source_frames, source_frames[1:]): + missing += max(0, current - previous - 1) + return missing + + +def should_preserve_video_timestamps(timeline: OutputTimeline) -> bool: + return timeline.timing_kind == "vfr" or normal_deleted_frame_count(timeline) > 0 + + +def should_use_audio_segments( + timeline: OutputTimeline, + audio_options: AudioEncodeOptions, +) -> bool: + if audio_options.mode != "aac": + return False + if len(timeline.audio_segments) > 1: + return True + if not timeline.audio_segments: + return False + start, duration = timeline.audio_segments[0] + return ( + abs(start - timeline.frames[0].start) > 0.001 + or abs(duration - timeline.duration_seconds) > 0.001 + ) + + +def y4m_command_for_timeline( + avs_runner: Path, + timeline: OutputTimeline, + *, + force_timeline_rate: bool = False, +) -> list[str]: + if not timeline.dropped_frame_count and not force_timeline_rate: + return [str(avs_runner), "--y4m"] + + fps = cfr_fps_for_timeline(timeline) + return [str(avs_runner), "--y4m-skip-drop", str(fps.numerator), str(fps.denominator)] + + +def cfr_fps_for_timeline(timeline: OutputTimeline) -> Fraction: + if timeline.duration_seconds <= 0: + raise RuntimeError("Cannot encode a zero-duration timeline") + return Fraction(len(timeline.frames) / timeline.duration_seconds).limit_denominator(1001000) + + +def timeline_frame_starts(timeline: OutputTimeline) -> list[float]: + starts: list[float] = [] + current = 0.0 + for frame in timeline.frames: + starts.append(current) + current += frame.duration + return starts + + +def avisynth_duration_differs( + clip_info: AvisynthClipInfo, + timeline: OutputTimeline, +) -> bool: + duration = clip_info.duration_seconds + if duration is None: + return False + return abs(duration - timeline.duration_seconds) > 0.05 + + +def encoded_duration_seconds( + clip_info: AvisynthClipInfo | None, + timeline: OutputTimeline, +) -> float: + if clip_info is None or clip_info.duration_seconds is None: + return timeline.duration_seconds + return clip_info.duration_seconds + + +def has_avisynth_speed_changes( + timelines: dict[Path, OutputTimeline], + clip_infos: dict[Path, AvisynthClipInfo], +) -> bool: + for path, timeline in timelines.items(): + clip_info = clip_infos.get(path) + if clip_info is not None and avisynth_duration_differs(clip_info, timeline): + return True + return False + + +def audio_tempo_factor(timeline: OutputTimeline, output_duration: float) -> float: + if output_duration <= 0: + raise RuntimeError("Encoded output duration must be positive") + return timeline.duration_seconds / output_duration + + +def audio_options_for_probe( + requested: AudioEncodeOptions, + probe: VideoProbe, + timeline: OutputTimeline, + output_duration: float, + *, + normal_deleted_frames: int = 0, +) -> AudioEncodeOptions: + if requested.mode == "none": + return requested + if probe.audio_stream_count <= 0: + print(" audio: source has no audio stream; output will be video-only") + return AudioEncodeOptions(mode="none") + if requested.mode == "copy" and normal_deleted_frames: + print(" audio: copy requested, but frame deletion needs audio cuts; output will be video-only") + return AudioEncodeOptions(mode="none") + if requested.mode == "copy" and abs(timeline.duration_seconds - output_duration) > 0.05: + print(" audio: copy requested, but speed-changed output needs audio re-encoding; output will be video-only") + return AudioEncodeOptions(mode="none") + return requested diff --git a/tools/video_formatting.py b/tools/video_formatting.py new file mode 100644 index 0000000..e5325ed --- /dev/null +++ b/tools/video_formatting.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from fractions import Fraction + + +def join_parts(parts: list[str | None]) -> str: + return ", ".join(part for part in parts if part) + + +def video_parts( + *, + codec: str | None = None, + width: int | None = None, + height: int | None = None, + pixel_format: str | None = None, + bit_depth: int | None = None, + matrix: str | None = None, + bit_rate: int | None = None, +) -> list[str | None]: + return [ + codec, + format_size(width, height), + format_chroma(pixel_format), + f"{bit_depth}-bit" if bit_depth is not None else None, + matrix, + format_bitrate(bit_rate), + ] + + +def audio_parts( + *, + codec: str | None = None, + channels: int | None = None, + sample_rate: int | None = None, + bit_rate: int | None = None, +) -> list[str | None]: + return [ + codec, + format_channels(channels), + f"{sample_rate} Hz" if sample_rate is not None else None, + format_bitrate(bit_rate), + ] + + +def format_size(width: int | None, height: int | None) -> str | None: + return f"{width}x{height}" if width is not None and height is not None else None + + +def format_chroma(pixel_format: str | None) -> str | None: + value = (pixel_format or "").lower() + for marker, label in (("420", "4:2:0"), ("422", "4:2:2"), ("444", "4:4:4")): + if marker in value: + return label + if value.startswith(("rgb", "bgr", "gbr")): + return "RGB" + if value.startswith("gray"): + return "gray" + return pixel_format + + +def chroma_family(pixel_format: str | None) -> str: + value = (pixel_format or "").lower() + if "444" in value: + return "444" + if "422" in value: + return "422" + return "420" + + +def format_channels(channels: int | None) -> str | None: + if channels is None: + return None + return f"{channels} channel{'s' if channels != 1 else ''}" + + +def format_bitrate(bit_rate: int | None) -> str | None: + if bit_rate is None: + return None + if bit_rate >= 1_000_000: + return f"{bit_rate / 1_000_000:.3g} Mbit/s" + return f"{bit_rate / 1_000:.3g} kbit/s" + + +def format_seconds(seconds: float | None) -> str: + return "unknown" if seconds is None else f"{seconds:.3f}s" + + +def format_frame_count(stream_count: int | None, timestamp_count: int) -> str: + if stream_count is not None and stream_count != timestamp_count: + return f"{stream_count} stream frames, {timestamp_count} timestamped frames" + count = stream_count if stream_count is not None else timestamp_count + return f"{count} frames" if count else "unknown frames" + + +def rate_value(value: str | None) -> float | None: + if not value: + return None + try: + rate = Fraction(value) + except (ValueError, ZeroDivisionError): + return None + return float(rate) if rate else None + + +def format_framerate(kind: str, value: str | None) -> str: + rate = rate_value(value) + rate_text = "unknown" if rate is None else f"{rate:.3f} fps" + kind = kind.upper() + return f"{kind}, average {rate_text}" if kind == "VFR" else f"{kind}, {rate_text}" + + +def matrix_name(matrix: int | None) -> str: + if matrix == 1: + return "Rec709" + if matrix in {5, 6}: + return "Rec601" + if matrix in {9, 10}: + return "Rec2020" + return "unknown" if matrix is None else f"unknown ({matrix})" diff --git a/tools/video_inputs.py b/tools/video_inputs.py new file mode 100644 index 0000000..718be35 --- /dev/null +++ b/tools/video_inputs.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".mts", ".m2ts", ".avi"} + + +@dataclass(frozen=True) +class VideoInput: + path: Path + drag_relative: Path + collapsed_relative: Path + + +@dataclass(frozen=True) +class _CollectedVideo: + path: Path + drag_relative: Path + + +def _sort_key(path: Path) -> str: + return str(path).lower() + + +def _is_video(path: Path) -> bool: + return path.is_file() and path.suffix.lower() in VIDEO_EXTS + + +def _collect_from_arg(arg: str) -> list[_CollectedVideo]: + path = Path(arg).expanduser() + if not path.exists(): + print(f"Skipping missing path: {arg}") + return [] + + resolved = path.resolve() + if _is_video(path): + return [_CollectedVideo(resolved, Path(path.name))] + + if path.is_dir(): + videos: list[_CollectedVideo] = [] + for child in sorted(path.rglob("*"), key=_sort_key): + if _is_video(child): + relative = Path(path.name) / child.relative_to(path) + videos.append(_CollectedVideo(child.resolve(), relative)) + return videos + + return [] + + +def _relative_depth(path: Path) -> int: + return len(path.parts) + + +def collect_video_inputs(args: list[str]) -> list[VideoInput]: + collected: dict[Path, _CollectedVideo] = {} + + for arg in args: + for item in _collect_from_arg(arg): + existing = collected.get(item.path) + if existing is None: + collected[item.path] = item + continue + if _relative_depth(item.drag_relative) > _relative_depth(existing.drag_relative): + collected[item.path] = item + + items = sorted(collected.values(), key=lambda item: _sort_key(item.drag_relative)) + if not items: + return [] + + collapsed = _collapse_drag_tree(items) + return [ + VideoInput( + path=item.path, + drag_relative=item.drag_relative, + collapsed_relative=collapsed[item.path], + ) + for item in items + ] + + +def _collapse_drag_tree(items: list[_CollectedVideo]) -> dict[Path, Path]: + if len(items) == 1: + return {items[0].path: Path(items[0].path.name)} + + parent_names = [str(item.drag_relative.parent) for item in items] + common_parent = Path(os.path.commonpath(parent_names)) + if str(common_parent) in {"", "."}: + common_parent = Path(".") + + collapsed: dict[Path, Path] = {} + used: dict[Path, Path] = {} + for item in items: + if common_parent == Path("."): + relative = item.drag_relative + else: + relative = item.drag_relative.relative_to(common_parent) + + conflict = used.get(relative) + if conflict is not None and conflict != item.path: + raise RuntimeError( + "Two different videos collapse to the same workspace path: " + f"{relative}" + ) + + used[relative] = item.path + collapsed[item.path] = relative + + return collapsed diff --git a/tools/video_options.py b/tools/video_options.py new file mode 100644 index 0000000..1476573 --- /dev/null +++ b/tools/video_options.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from datetime import timezone +from pathlib import Path + +from tools.avisynth_runner import ( + AvisynthRunnerSet, + runner_set_from_env_or_bundled, + validate_runner_path, +) +from tools.console import prompt_input, prompt_yes_no +from tools.timezones import local_timezone, parse_timezone_offset, timezone_to_string +from tools.video_encode_output import ( + ENCODER_PRESETS, + AudioEncodeOptions, + VideoCodecOptions, + default_audio_bitrate, +) + + +@dataclass(frozen=True) +class OutputNamingOptions: + use_timestamps: bool + timezone_value: timezone + + +def ask_ffms2_plugin_path() -> Path | None: + env_path = os.environ.get("MBT_FFMS2_PLUGIN", "").strip() + if env_path: + return _validate_plugin_path(env_path) + + if not sys.stdin.isatty() or not _env_truthy("MBT_ASK_FFMS2_PLUGIN"): + return None + + answer = prompt_input( + "\nFFMS2 plugin path for Avisynth LoadPlugin " + "(blank = use Avisynth autoload): " + ).strip() + if not answer: + return None + return _validate_plugin_path(answer) + + +def _env_truthy(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _validate_plugin_path(raw_path: str) -> Path: + plugin_path = Path(raw_path.strip().strip('"')).expanduser() + if not plugin_path.is_file(): + raise RuntimeError(f"FFMS2 plugin file not found: {plugin_path}") + return plugin_path.resolve() + + +def ask_avisynth_runners() -> AvisynthRunnerSet | None: + runners = runner_set_from_env_or_bundled() + if runners is not None: + return runners + + if not sys.stdin.isatty(): + return None + + print( + "\nAviSynth runner is needed for validation and encoding.\n" + "Use media-batch-tools' mbt_avs_runner executable. The script first " + "looks for the bundled runner automatically. On Windows, the default is " + "64-bit mbt_avs_runner.exe; 32-bit scripts can use " + "mbt_avs_runner32.exe via a trailing #32bit marker. If you leave this " + "blank, the workspace and editable .avs files are created, but " + "validation and encoding stop." + ) + answer = prompt_input( + "AviSynth runner executable path (blank = create workspace only): " + ).strip() + if not answer: + return None + runner = validate_runner_path(answer) + return AvisynthRunnerSet(default=runner, runner64=runner) + + +def require_avisynth_runners_for_encode() -> AvisynthRunnerSet: + runners = runner_set_from_env_or_bundled() + if runners is not None: + return runners + + if not sys.stdin.isatty(): + raise RuntimeError( + "Build tools/avisynth_runner/mbt_avs_runner on Linux, provide " + "tools/avisynth_runner/mbt_avs_runner.exe on Windows, or set " + "MBT_AVS_RUNNER before enabling video encode. For 32-bit Windows " + "AviSynth, set MBT_AVS_RUNNER32." + ) + + print( + "\nEncoding needs media-batch-tools' AviSynth runner executable " + "(mbt_avs_runner or mbt_avs_runner.exe)." + ) + answer = prompt_input("AviSynth runner executable path: ").strip() + if not answer: + raise RuntimeError("Avisynth runner path is required for encoding.") + runner = validate_runner_path(answer) + return AvisynthRunnerSet(default=runner, runner64=runner) + + +def ask_encode_video_only() -> bool: + env_value = os.environ.get("MBT_ENCODE_VIDEO") + if env_value is not None: + return env_value.strip().lower() in {"1", "true", "yes", "y", "on"} + if not sys.stdin.isatty(): + return False + return prompt_yes_no("Encode validated outputs now?", default=False) + + +def ask_output_naming_options( + *, + default_timezone: timezone | None = None, +) -> OutputNamingOptions: + env_value = os.environ.get("MBT_VIDEO_TIMESTAMP_NAMES") + if env_value is not None: + use_timestamps = env_value.strip().lower() in {"1", "true", "yes", "y", "on"} + elif sys.stdin.isatty(): + use_timestamps = prompt_yes_no( + "Name encoded files from their edited beginning timestamp?", + default=True, + ) + else: + use_timestamps = False + + default_timezone = default_timezone or local_timezone() + env_timezone = os.environ.get("MBT_VIDEO_TIMEZONE", "").strip() + if not use_timestamps: + timezone_value = default_timezone + elif env_timezone: + try: + timezone_value = parse_timezone_offset(env_timezone) + except ValueError as exc: + raise RuntimeError(f"Invalid MBT_VIDEO_TIMEZONE: {exc}") from exc + elif sys.stdin.isatty(): + while True: + answer = prompt_input( + "Output filename timezone " + f"[{timezone_to_string(default_timezone)}]: " + ).strip() + if not answer: + timezone_value = default_timezone + break + try: + timezone_value = parse_timezone_offset(answer) + break + except ValueError as exc: + print(exc) + else: + timezone_value = default_timezone + + return OutputNamingOptions( + use_timestamps=use_timestamps, + timezone_value=timezone_value, + ) + + +def ask_video_codec_options() -> VideoCodecOptions: + codec = _codec_from_env() + extension = _container_extension_from_env() + crf = _int_from_env("MBT_VIDEO_CRF") + preset = os.environ.get("MBT_VIDEO_PRESET", "").strip() + profile = _optional_env("MBT_VIDEO_PROFILE") + level = _optional_env("MBT_VIDEO_LEVEL") + threads = _threads_from_env() + + if sys.stdin.isatty(): + if extension is None: + extension = _prompt_container_extension() + if codec is None: + codec = _prompt_codec() + if crf is None: + default_crf = 16 if codec == "libx264" else 21 + crf = _prompt_int(f"CRF [{default_crf}]: ", default=default_crf) + if not preset: + preset = _prompt_preset(default="slow") + if "MBT_VIDEO_THREADS" not in os.environ: + threads = _prompt_threads(default=None) + else: + extension = extension or ".mp4" + codec = codec or "libx265" + crf = crf if crf is not None else (16 if codec == "libx264" else 21) + preset = preset or "slow" + + return VideoCodecOptions( + codec=codec, + crf=crf, + preset=preset, + extension=extension, + profile=profile, + level=level, + threads=threads, + ) + + +def _prompt_preset(*, default: str) -> str: + options = "/".join(ENCODER_PRESETS) + while True: + value = prompt_input(f"Encoder preset [{default}] ({options}): ").strip().lower() + if not value: + return default + if value in ENCODER_PRESETS: + return value + print(f"Preset must be one of: {options}.") + + +def _threads_from_env() -> int | None: + raw = os.environ.get("MBT_VIDEO_THREADS", "").strip().lower() + if not raw or raw == "auto": + return None + try: + value = int(raw) + except ValueError as exc: + raise RuntimeError("MBT_VIDEO_THREADS must be auto or a positive integer") from exc + if value < 1: + raise RuntimeError("MBT_VIDEO_THREADS must be auto or a positive integer") + return value + + +def _prompt_threads(*, default: int | None) -> int | None: + default_text = str(default) if default is not None else "auto" + while True: + value = prompt_input(f"Encoder threads [{default_text}] (auto or positive integer): ").strip().lower() + if not value or value == "auto": + return default + try: + threads = int(value) + except ValueError: + print("Threads must be auto or a positive integer.") + continue + if threads >= 1: + return threads + print("Threads must be auto or a positive integer.") + + +def ask_audio_options( + *, + has_speed_changes: bool, + container_extension: str, +) -> AudioEncodeOptions: + env_mode = os.environ.get("MBT_VIDEO_AUDIO", "").strip().lower() + env_bitrate = os.environ.get("MBT_VIDEO_AUDIO_BITRATE", "").strip() + preserve_pitch = ask_audio_pitch_option(has_speed_changes=has_speed_changes) + if env_mode: + mode = _normalize_audio_mode(env_mode) + _validate_audio_container(mode, container_extension) + return AudioEncodeOptions( + mode=mode, + bitrate=env_bitrate or default_audio_bitrate(mode), + preserve_pitch=preserve_pitch, + ) + + if not sys.stdin.isatty(): + return AudioEncodeOptions(mode="none", preserve_pitch=preserve_pitch) + + while True: + if container_extension == ".mkv": + prompt = "Audio: Enter=Opus, a=AAC, f=FLAC, c=copy/trim audio, n=no audio: " + else: + prompt = "Audio: Enter=AAC re-encode, c=copy/trim audio, n=no audio: " + answer = prompt_input(prompt).strip().lower() + if not answer: + mode = "opus" if container_extension == ".mkv" else "aac" + break + if answer in {"a", "aac"}: + mode = "aac" + break + if container_extension == ".mkv" and answer in {"o", "opus"}: + mode = "opus" + break + if container_extension == ".mkv" and answer in {"f", "flac"}: + mode = "flac" + break + if answer in {"c", "copy"}: + mode = "copy" + break + if answer in {"n", "no", "none"}: + mode = "none" + break + print("Please choose Enter, c, or n.") + + bitrate = default_audio_bitrate(mode) + if mode in {"aac", "opus"}: + label = "AAC" if mode == "aac" else "Opus" + bitrate = prompt_input(f"{label} audio bitrate [{bitrate}]: ").strip() or bitrate + return AudioEncodeOptions(mode=mode, bitrate=bitrate, preserve_pitch=preserve_pitch) + + +def ask_audio_pitch_option(*, has_speed_changes: bool) -> bool: + env_value = os.environ.get("MBT_VIDEO_AUDIO_PITCH", "").strip().lower() + if env_value: + if env_value in {"preserve", "preserved", "keep", "same"}: + return True + if env_value in {"shift", "change", "changed", "speed"}: + return False + raise RuntimeError("MBT_VIDEO_AUDIO_PITCH must be preserve or shift") + + if not has_speed_changes or not sys.stdin.isatty(): + return True + + while True: + answer = prompt_input( + "Speed-changed audio pitch: Enter=preserve pitch, s=shift with speed: " + ).strip().lower() + if not answer or answer in {"p", "preserve", "keep", "same"}: + return True + if answer in {"s", "shift", "change", "speed"}: + return False + print("Please choose Enter or s.") + + +def _normalize_audio_mode(value: str) -> str: + if value in {"aac", "reencode", "re-encode"}: + return "aac" + if value in {"opus", "libopus"}: + return "opus" + if value in {"flac", "lossless"}: + return "flac" + if value in {"copy", "streamcopy", "stream-copy"}: + return "copy" + if value in {"none", "no", "off", "0"}: + return "none" + raise RuntimeError("MBT_VIDEO_AUDIO must be aac, opus, flac, copy, or none") + + +def _validate_audio_container(mode: str, container_extension: str) -> None: + if container_extension == ".mp4" and mode in {"opus", "flac"}: + raise RuntimeError("MP4 output supports aac, copy, or none audio in this tool") + + +def _container_extension_from_env() -> str | None: + raw = os.environ.get("MBT_VIDEO_CONTAINER", "").strip().lower() + if not raw: + return None + return _normalize_container_extension(raw) + + +def _prompt_container_extension() -> str: + while True: + answer = prompt_input("Output container: Enter=MP4, m=MKV: ").strip().lower() + if not answer: + return ".mp4" + try: + return _normalize_container_extension(answer) + except ValueError as exc: + print(exc) + + +def _normalize_container_extension(value: str) -> str: + if value in {"mp4", ".mp4"}: + return ".mp4" + if value in {"m", "mkv", ".mkv", "matroska"}: + return ".mkv" + raise ValueError("container must be mp4 or mkv") + + +def _codec_from_env() -> str | None: + raw = os.environ.get("MBT_VIDEO_CODEC", "").strip().lower() + if not raw: + return None + return _normalize_codec(raw) + + +def _prompt_codec() -> str: + while True: + answer = prompt_input("Video codec: x264, Enter=x265: ").strip().lower() + if not answer: + return "libx265" + try: + return _normalize_codec(answer) + except ValueError as exc: + print(exc) + + +def _normalize_codec(value: str) -> str: + if value in {"x264", "h264", "libx264"}: + return "libx264" + if value in {"x265", "h265", "hevc", "libx265"}: + return "libx265" + raise ValueError("codec must be x264 or x265") + + +def _int_from_env(name: str) -> int | None: + raw = os.environ.get(name, "").strip() + if not raw: + return None + try: + return int(raw) + except ValueError as exc: + raise RuntimeError(f"{name} must be an integer") from exc + + +def _optional_env(name: str) -> str | None: + value = os.environ.get(name, "").strip() + return value or None + + +def _prompt_int(prompt: str, *, default: int) -> int: + while True: + answer = prompt_input(prompt).strip() + if not answer: + return default + try: + return int(answer) + except ValueError: + print("Please enter an integer.") diff --git a/tools/video_outputs.py b/tools/video_outputs.py new file mode 100644 index 0000000..6422242 --- /dev/null +++ b/tools/video_outputs.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import os +import shutil +import sys +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from tools.console import prompt_input, prompt_yes_no +from tools.exiftool import run_exiftool_command +from tools.filenames import format_rounded_filename_stem, round_datetime_to_second +from tools.metadata_copy import copy_meaningful_metadata +from tools.video_inputs import VideoInput +from tools.video_options import OutputNamingOptions +from tools.video_probe import VideoProbe, probe_video +from tools.video_timeline import OutputTimeline +from tools.video_formatting import join_parts, video_parts + + +@dataclass(frozen=True) +class EncodedItem: + input_item: VideoInput + output: Path + + +def planned_output_path( + *, + script: Path, + timeline: OutputTimeline, + probe: VideoProbe, + naming: OutputNamingOptions, + extension: str, + planned_outputs: set[Path], + overwrite_existing: bool = True, +) -> Path: + if naming.use_timestamps: + output_begin = output_begin_timestamp(probe, timeline, naming.timezone_value) + stem = format_rounded_filename_stem(output_begin) + candidate = script.parent / f"{stem}{extension}" + else: + candidate = script.with_suffix(extension) + + for index in range(10000): + target = candidate if index == 0 else candidate.with_name( + f"{candidate.stem}-{index}{candidate.suffix}" + ) + if target.resolve() in planned_outputs: + continue + if not overwrite_existing and target.exists(): + continue + planned_outputs.add(target.resolve()) + return target + + raise RuntimeError(f"Could not find an available output name for {candidate}") + + +def output_begin_timestamp( + probe: VideoProbe, + timeline: OutputTimeline, + output_timezone: timezone, +) -> datetime: + if probe.metadata_end is None: + raise RuntimeError(f"No metadata end timestamp found for {probe.path}") + if probe.duration_seconds is None: + raise RuntimeError(f"No source duration found for {probe.path}") + + source_end = probe.metadata_end + if source_end.tzinfo is None: + source_end = source_end.replace(tzinfo=timezone.utc) + source_begin = source_end - timedelta(seconds=probe.duration_seconds) + first_frame_start = timeline.frames[0].start + return (source_begin + timedelta(seconds=first_frame_start)).astimezone( + output_timezone + ) + + +def output_end_timestamp( + probe: VideoProbe, + timeline: OutputTimeline, + output_duration: float, +) -> datetime: + begin = output_begin_timestamp(probe, timeline, timezone.utc) + return begin + timedelta(seconds=output_duration) + + +def write_video_end_timestamp( + exiftool: str, + output: Path, + probe: VideoProbe, + timeline: OutputTimeline, + output_duration: float, +) -> None: + if output.suffix.lower() not in {".mp4", ".mov"}: + print(f" skipped container timestamp write for {output.suffix}") + return + end_time = round_datetime_to_second( + output_end_timestamp(probe, timeline, output_duration).astimezone(timezone.utc) + ) + timestamp = end_time.strftime("%Y:%m:%d %H:%M:%S") + args = [ + "-overwrite_original", + f"-QuickTime:CreateDate={timestamp}", + f"-QuickTime:ModifyDate={timestamp}", + f"-QuickTime:TrackCreateDate={timestamp}", + f"-QuickTime:TrackModifyDate={timestamp}", + f"-QuickTime:MediaCreateDate={timestamp}", + f"-QuickTime:MediaModifyDate={timestamp}", + str(output), + ] + run_exiftool_command( + exiftool, + args, + check=True, + capture_output=True, + text=True, + ) + print(f" wrote video end timestamp: {timestamp} UTC") + + +def copy_video_metadata(exiftool: str, source: Path, output: Path) -> None: + copy_meaningful_metadata(exiftool, source, output) + if output.suffix.lower() in {".mp4", ".mov"}: + print(" copied source metadata") + else: + print(" copied source metadata where supported by container") + + +def validate_encoded_video( + ffprobe: str, + output: Path, + expected_timeline: OutputTimeline, + *, + expected_duration: float, + expected_timestamps: list[float] | None = None, + expected_audio: bool, +) -> VideoProbe: + probe = probe_video(ffprobe, output, {}) + expected_frames = len(expected_timeline.frames) + if probe.timing.frame_count != expected_frames: + raise RuntimeError( + f"Encoded output frame count mismatch for {output}: " + f"expected {expected_frames}, got {probe.timing.frame_count}" + ) + if probe.duration_seconds is None: + raise RuntimeError(f"Encoded output duration is unknown for {output}") + duration_delta = abs(probe.duration_seconds - expected_duration) + duration_tolerance = 0.05 + if expected_timestamps is not None and expected_timestamps: + # MP4 packet start timestamps can validate exactly, but ffprobe often + # reports video stream duration without the final sample duration. + final_sample_duration = expected_duration - expected_timestamps[-1] + duration_tolerance = max(duration_tolerance, final_sample_duration + 0.05) + if duration_delta > duration_tolerance: + raise RuntimeError( + f"Encoded output duration mismatch for {output}: expected " + f"{expected_duration:.3f}s, got " + f"{probe.duration_seconds:.3f}s" + ) + if expected_timestamps is not None: + validate_encoded_timestamps(output, probe.frame_timestamps, expected_timestamps) + elif probe.timing.timing_kind != "cfr": + raise RuntimeError( + f"Encoded output should be CFR for this milestone, got " + f"{probe.timing.timing_kind.upper()} for {output}" + ) + if expected_audio and probe.audio_stream_count <= 0: + raise RuntimeError(f"Encoded output is missing expected audio: {output}") + print( + " verified output: " + f"{probe.timing.frame_count} frame(s), {probe.duration_seconds:.3f}s, " + f"{probe.timing.timing_kind.upper()}, " + f"{probe.audio_stream_count} audio stream(s)" + ) + print( + " output video: " + + join_parts( + video_parts( + codec=probe.codec, + width=probe.width, + height=probe.height, + pixel_format=probe.pixel_format, + bit_depth=probe.bit_depth, + matrix=probe.color_matrix, + bit_rate=probe.video_bit_rate, + ) + ) + ) + return probe + + +def validate_encoded_timestamps( + output: Path, + actual: list[float], + expected: list[float], +) -> None: + if len(actual) != len(expected): + raise RuntimeError( + f"Encoded output timestamp count mismatch for {output}: " + f"expected {len(expected)}, got {len(actual)}" + ) + for index, (actual_timestamp, expected_timestamp) in enumerate(zip(actual, expected)): + delta = abs(actual_timestamp - expected_timestamp) + if delta > 0.001: + raise RuntimeError( + f"Encoded output timestamp mismatch for {output} at frame {index}: " + f"expected {expected_timestamp:.6f}s, got {actual_timestamp:.6f}s" + ) + + +def finalize_encoded_outputs(root: Path, encoded_items: list[EncodedItem]) -> None: + action = ask_final_action() + if action == "leave": + print("\nLeaving encoded output(s) in the workspace.") + return + + print("\nMoving encoded output(s) to source folder(s)...") + backups: list[Path] = [] + moved: list[Path] = [] + for item in encoded_items: + backup = move_encoded_output_to_source_dir(root, item, action) + if backup is not None: + backups.append(backup) + moved.append(item.input_item.path.parent / item.output.name) + + for path in moved: + print(f" moved: {path}") + for path in backups: + print(f" original backup: {path}") + + if ask_delete_workspace_after_move(has_backups=bool(backups)): + shutil.rmtree(root) + print(f"Deleted workspace: {root}") + + +def ask_final_action() -> str: + env_value = os.environ.get("MBT_VIDEO_FINAL_ACTION", "").strip().lower() + actions = { + "leave": "leave", + "l": "leave", + "replace": "replace", + "move-replace": "replace", + "delete-originals": "replace", + "backup": "backup", + "move-backup": "backup", + "backup-originals": "backup", + } + if env_value: + try: + return actions[env_value] + except KeyError as exc: + raise RuntimeError( + "MBT_VIDEO_FINAL_ACTION must be leave, replace, or backup" + ) from exc + + if not sys.stdin.isatty(): + return "leave" + + while True: + answer = prompt_input( + "\nFinished files: Enter=leave in workspace, " + "r=move to source dirs and delete originals, " + "b=move to source dirs and back up originals: " + ).strip().lower() + if not answer: + return "leave" + if answer in {"r", "replace"}: + return "replace" + if answer in {"b", "backup"}: + return "backup" + print("Please choose Enter, r, or b.") + + +def move_encoded_output_to_source_dir( + root: Path, + item: EncodedItem, + action: str, +) -> Path | None: + source = item.input_item.path + output = item.output + target = source.parent / output.name + backup: Path | None = None + + if target.exists() and target.resolve() != source.resolve(): + raise RuntimeError(f"Destination already exists: {target}") + + if action == "backup": + backup = root / "originals" / item.input_item.collapsed_relative + backup.parent.mkdir(parents=True, exist_ok=True) + backup = unique_path(backup) + shutil.move(str(source), str(backup)) + elif action == "replace": + if source.exists(): + source.unlink() + + if target.exists(): + raise RuntimeError(f"Destination already exists: {target}") + + shutil.move(str(output), str(target)) + return backup + + +def ask_delete_workspace_after_move(*, has_backups: bool) -> bool: + env_value = os.environ.get("MBT_VIDEO_DELETE_WORKSPACE", "").strip().lower() + if env_value: + return env_value in {"1", "true", "yes", "y", "on"} + if not sys.stdin.isatty(): + return False + if has_backups: + return prompt_yes_no( + "Delete video_workspace too? This will delete original backups stored there.", + default=False, + ) + return prompt_yes_no("Delete video_workspace after moving outputs?", default=True) diff --git a/tools/video_probe.py b/tools/video_probe.py new file mode 100644 index 0000000..a188bcb --- /dev/null +++ b/tools/video_probe.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +import json +import re +import statistics +import subprocess +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from tools.executables import require_executable + + +VIDEO_TIME_TAGS = ( + "QuickTime:MediaCreateDate", + "QuickTime:TrackCreateDate", + "QuickTime:CreateDate", + "QuickTime:MediaModifyDate", + "QuickTime:TrackModifyDate", + "QuickTime:ModifyDate", + "XMP:DateTimeOriginal", + "XMP:CreateDate", +) +VIDEO_BEGIN_TIME_TAGS = ( + "H264:DateTimeOriginal", +) +VIDEO_TIMEZONE_TAGS = ( + "QuickTime:AndroidTimeZone", + "QuickTime:TimeZone", + "EXIF:OffsetTimeOriginal", + "XMP:OffsetTimeOriginal", +) +GPS_LATITUDE_TAGS = ( + "Composite:GPSLatitude", + "EXIF:GPSLatitude", + "GPS:GPSLatitude", + "QuickTime:GPSLatitude", +) +GPS_LONGITUDE_TAGS = ( + "Composite:GPSLongitude", + "EXIF:GPSLongitude", + "GPS:GPSLongitude", + "QuickTime:GPSLongitude", +) +GPS_POSITION_TAGS = ( + "Composite:GPSPosition", + "QuickTime:GPSCoordinates", + "Composite:GPSCoordinates", +) + +DATETIME_RE = re.compile( + r"(?P<Y>\d{4})[:\-]?(?P<M>\d{2})[:\-]?(?P<D>\d{2})" + r"(?:[ T_])?" + r"(?P<h>\d{2}):?(?P<m>\d{2}):?(?P<s>\d{2})" + r"(?:[.,](?P<sub>\d+))?" + r"(?:\s*(?P<tz>Z|[+-]\d{2}:?\d{2}))?" +) +DMS_RE = re.compile( + r"(?P<deg>-?\d+(?:\.\d+)?)\D+" + r"(?P<min>\d+(?:\.\d+)?)?\D*" + r"(?P<sec>\d+(?:\.\d+)?)?\D*" + r"(?P<hem>[NSEW])?", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class FrameTimingSummary: + frame_count: int + first_pts: float | None + last_pts: float | None + min_delta_ms: float | None + median_delta_ms: float | None + max_delta_ms: float | None + unique_delta_count: int + common_deltas_ms: list[tuple[float, int]] + timing_kind: str + + +@dataclass(frozen=True) +class AudioStreamProbe: + codec: str | None + channels: int | None + sample_rate: int | None + bit_rate: int | None + + +@dataclass(frozen=True) +class VideoProbe: + path: Path + codec: str | None + width: int | None + height: int | None + pixel_format: str | None + bit_depth: int | None + video_bit_rate: int | None + color_space: str | None + color_matrix: str | None + expected_color_matrix: str | None + duration_seconds: float | None + frame_timestamps: list[float] + stream_frame_count: int | None + audio_streams: list[AudioStreamProbe] + audio_stream_count: int + audio_sample_rate: int | None + avg_frame_rate: str | None + r_frame_rate: str | None + time_base: str | None + timing: FrameTimingSummary + metadata_end: datetime | None + metadata_begin_tag: datetime | None + source_timezone: timezone | None + computed_begin: datetime | None + filename_begin: datetime | None + gps_latitude: float | None + gps_longitude: float | None + warnings: list[str] + + +def require_tool(name: str) -> str: + return require_executable(name) + + +def probe_video(ffprobe: str, path: Path, metadata: dict) -> VideoProbe: + stream, format_info = _probe_stream(ffprobe, path) + stream_duration = _to_float(stream.get("duration")) + format_duration = _to_float(format_info.get("duration")) + duration = stream_duration if stream_duration is not None else format_duration + audio_streams = _probe_audio_streams(ffprobe, path, duration) + audio_sample_rate = audio_streams[0].sample_rate if audio_streams else None + timestamps, video_packet_bytes = _probe_video_packets(ffprobe, path) + timing = summarize_frame_timing(timestamps) + video_bit_rate = _to_int(stream.get("bit_rate")) or _average_bit_rate( + video_packet_bytes, + duration, + ) + + end_value = first_tag(metadata, VIDEO_TIME_TAGS) + metadata_end = parse_exif_datetime(end_value, assume_utc=True) + metadata_begin_tag = parse_exif_datetime( + first_tag(metadata, VIDEO_BEGIN_TIME_TAGS), assume_utc=True + ) + timezone_value = first_tag(metadata, VIDEO_TIMEZONE_TAGS) + metadata_timezone = _video_timezone(metadata) + source_timezone = metadata_timezone + explicit_end = parse_exif_datetime(end_value) + if source_timezone is None and explicit_end is not None: + source_timezone = explicit_end.tzinfo + computed_begin = None + filename_begin = None + if metadata_end is not None and duration is not None: + computed_begin = metadata_end - timedelta(seconds=duration) + filename_begin = metadata_end - timedelta(seconds=round_half_up(duration)) + gps_latitude, gps_longitude = _gps_coordinates(metadata) + + warnings: list[str] = [] + if metadata_end is None: + warnings.append("No metadata end timestamp found.") + if timezone_value is not None and metadata_timezone is None: + warnings.append(f"Invalid source timezone metadata: {timezone_value}") + if duration is None: + warnings.append("No ffprobe duration found.") + if metadata_begin_tag is not None: + warnings.append( + "Found a possible beginning timestamp tag; v1 still treats the " + "normal video metadata timestamp as the end timestamp." + ) + if timing.timing_kind == "unknown": + warnings.append("Not enough frame timestamps to classify CFR/VFR timing.") + + return VideoProbe( + path=path, + codec=_as_str(stream.get("codec_name")), + width=_to_int(stream.get("width")), + height=_to_int(stream.get("height")), + pixel_format=_as_str(stream.get("pix_fmt")), + bit_depth=_video_bit_depth(stream), + video_bit_rate=video_bit_rate, + color_space=_as_str(stream.get("color_space")), + color_matrix=_avisynth_matrix_from_color_space(_as_str(stream.get("color_space"))), + expected_color_matrix=_expected_color_matrix( + _to_int(stream.get("width")), + _to_int(stream.get("height")), + ), + duration_seconds=duration, + frame_timestamps=timestamps, + stream_frame_count=_to_int(stream.get("nb_frames")), + audio_streams=audio_streams, + audio_stream_count=len(audio_streams), + audio_sample_rate=audio_sample_rate, + avg_frame_rate=_as_str(stream.get("avg_frame_rate")), + r_frame_rate=_as_str(stream.get("r_frame_rate")), + time_base=_as_str(stream.get("time_base")), + timing=timing, + metadata_end=metadata_end, + metadata_begin_tag=metadata_begin_tag, + source_timezone=source_timezone, + computed_begin=computed_begin, + filename_begin=filename_begin, + gps_latitude=gps_latitude, + gps_longitude=gps_longitude, + warnings=warnings, + ) + + +def _probe_stream(ffprobe: str, path: Path) -> tuple[dict, dict]: + command = [ + ffprobe, + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name,width,height,pix_fmt,bits_per_raw_sample,bits_per_sample,bit_rate,color_space,color_range,color_transfer,color_primaries,avg_frame_rate,r_frame_rate,time_base,duration,nb_frames", + "-show_entries", + "format=duration", + "-of", + "json", + str(path), + ] + completed = subprocess.run(command, check=True, capture_output=True, text=True) + data = json.loads(completed.stdout or "{}") + streams = data.get("streams") or [] + if not streams: + raise RuntimeError(f"No video stream found in {path}") + return streams[0], data.get("format") or {} + + +def _probe_audio_streams( + ffprobe: str, + path: Path, + fallback_duration: float | None, +) -> list[AudioStreamProbe]: + command = [ + ffprobe, + "-v", + "error", + "-select_streams", + "a", + "-show_entries", + "stream=index,codec_name,channels,sample_rate,bit_rate,duration", + "-show_entries", + "packet=stream_index,size", + "-of", + "json", + str(path), + ] + completed = subprocess.run(command, check=True, capture_output=True, text=True) + data = json.loads(completed.stdout or "{}") + streams = data.get("streams") or [] + packet_bytes: Counter[int] = Counter() + for packet in data.get("packets") or []: + stream_index = _to_int(packet.get("stream_index")) + size = _to_int(packet.get("size")) + if stream_index is not None and size is not None: + packet_bytes[stream_index] += size + return [ + AudioStreamProbe( + codec=_as_str(stream.get("codec_name")), + channels=_to_int(stream.get("channels")), + sample_rate=_to_int(stream.get("sample_rate")), + bit_rate=( + _to_int(stream.get("bit_rate")) + or _average_bit_rate( + packet_bytes[_to_int(stream.get("index")) or 0], + _to_float(stream.get("duration")) or fallback_duration, + ) + ), + ) + for stream in streams + ] + + +def _probe_video_packets(ffprobe: str, path: Path) -> tuple[list[float], int]: + command = [ + ffprobe, + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "packet=pts_time,size", + "-of", + "json", + str(path), + ] + completed = subprocess.run(command, check=True, capture_output=True, text=True) + data = json.loads(completed.stdout or "{}") + timestamps: list[float] = [] + total_bytes = 0 + for packet in data.get("packets") or []: + value = _to_float(packet.get("pts_time")) + if value is not None: + timestamps.append(value) + total_bytes += _to_int(packet.get("size")) or 0 + return sorted(timestamps), total_bytes + + +def _average_bit_rate(total_bytes: int, duration: float | None) -> int | None: + if total_bytes <= 0 or duration is None or duration <= 0: + return None + return round(total_bytes * 8 / duration) + + +def summarize_frame_timing(timestamps: list[float]) -> FrameTimingSummary: + if len(timestamps) < 2: + return FrameTimingSummary( + frame_count=len(timestamps), + first_pts=timestamps[0] if timestamps else None, + last_pts=timestamps[-1] if timestamps else None, + min_delta_ms=None, + median_delta_ms=None, + max_delta_ms=None, + unique_delta_count=0, + common_deltas_ms=[], + timing_kind="unknown", + ) + + deltas = [ + (current - previous) * 1000 + for previous, current in zip(timestamps, timestamps[1:]) + if current >= previous + ] + if not deltas: + timing_kind = "unknown" + median_delta = None + max_deviation = None + else: + median_delta = statistics.median(deltas) + max_deviation = max(abs(delta - median_delta) for delta in deltas) + timing_kind = "vfr" if max_deviation > 2.0 else "cfr" + + rounded = [round(delta, 3) for delta in deltas] + counter = Counter(rounded) + + return FrameTimingSummary( + frame_count=len(timestamps), + first_pts=timestamps[0], + last_pts=timestamps[-1], + min_delta_ms=round(min(deltas), 3) if deltas else None, + median_delta_ms=round(median_delta, 3) if median_delta is not None else None, + max_delta_ms=round(max(deltas), 3) if deltas else None, + unique_delta_count=len(counter), + common_deltas_ms=counter.most_common(5), + timing_kind=timing_kind, + ) + + +def first_tag(metadata: dict, tags: tuple[str, ...]) -> object | None: + for tag in tags: + if tag in metadata: + return metadata[tag] + return None + + +def _gps_coordinates(metadata: dict) -> tuple[float | None, float | None]: + latitude = _parse_gps_coordinate(first_tag(metadata, GPS_LATITUDE_TAGS)) + longitude = _parse_gps_coordinate(first_tag(metadata, GPS_LONGITUDE_TAGS)) + if latitude is not None and longitude is not None: + return latitude, longitude + + position = first_tag(metadata, GPS_POSITION_TAGS) + if position is None: + return latitude, longitude + pair = _parse_gps_pair(position) + if pair == (None, None): + return latitude, longitude + return pair + + +def _parse_gps_pair(value: object) -> tuple[float | None, float | None]: + if isinstance(value, (list, tuple)) and len(value) >= 2: + return _parse_gps_coordinate(value[0]), _parse_gps_coordinate(value[1]) + text = str(value).strip() + if not text: + return None, None + if "," in text: + left, right = text.split(",", 1) + return _parse_gps_coordinate(left), _parse_gps_coordinate(right) + parts = text.split() + if len(parts) >= 2: + return _parse_gps_coordinate(parts[0]), _parse_gps_coordinate(parts[1]) + return None, None + + +def _parse_gps_coordinate(value: object) -> float | None: + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return None + try: + return float(text) + except ValueError: + pass + + hemisphere = text[-1:].upper() + sign = -1.0 if hemisphere in {"S", "W"} else 1.0 + match = DMS_RE.search(text) + if not match: + return None + degrees = float(match.group("deg")) + minutes = float(match.group("min") or 0) + seconds = float(match.group("sec") or 0) + if degrees < 0: + sign = -1.0 + degrees = abs(degrees) + return sign * (degrees + minutes / 60.0 + seconds / 3600.0) + + +def parse_exif_datetime(value: object, assume_utc: bool = False) -> datetime | None: + if value is None: + return None + match = DATETIME_RE.search(str(value).strip()) + if not match: + return None + + tzinfo = None + tz_text = match.group("tz") + if tz_text == "Z": + tzinfo = timezone.utc + elif tz_text: + tzinfo = _parse_timezone_offset(tz_text) + + parsed = datetime( + int(match.group("Y")), + int(match.group("M")), + int(match.group("D")), + int(match.group("h")), + int(match.group("m")), + int(match.group("s")), + tzinfo=tzinfo, + ) + if assume_utc and parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _parse_timezone_offset(value: str) -> timezone: + text = value.replace(":", "") + sign = 1 if text[0] == "+" else -1 + hours = int(text[1:3]) + minutes = int(text[3:5]) + return timezone(sign * timedelta(hours=hours, minutes=minutes)) + + +def _video_timezone(metadata: dict) -> timezone | None: + value = first_tag(metadata, VIDEO_TIMEZONE_TAGS) + if value is None: + return None + text = str(value).strip() + if not re.fullmatch(r"[+-]\d{2}:?\d{2}", text): + return None + return _parse_timezone_offset(text) + + +def round_half_up(value: float) -> int: + return int(value + 0.5) + + +def _to_float(value: object) -> float | None: + if value is None or value == "N/A": + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _to_int(value: object) -> int | None: + if value is None or value == "N/A": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _video_bit_depth(stream: dict) -> int | None: + for key in ("bits_per_raw_sample", "bits_per_sample"): + value = _to_int(stream.get(key)) + if value: + return value + + pix_fmt = _as_str(stream.get("pix_fmt")) or "" + match = re.search(r"p(?P<bits>10|12|14|16)(?:le|be)?$", pix_fmt) + if match: + return int(match.group("bits")) + if pix_fmt: + return 8 + return None + + +def _avisynth_matrix_from_color_space(color_space: str | None) -> str | None: + if not color_space: + return None + normalized = color_space.lower() + if normalized in {"bt709", "bt709nc"}: + return "Rec709" + if normalized in {"smpte170m", "bt470bg", "fcc"}: + return "Rec601" + if normalized in {"bt2020nc", "bt2020ncl", "bt2020c", "bt2020cl"}: + return "Rec2020" + return None + + +def _expected_color_matrix(width: int | None, height: int | None) -> str | None: + if width is None or height is None: + return None + if width >= 3840 or height >= 2160: + return "Rec2020" + if width >= 1280 or height >= 720: + return "Rec709" + return "Rec601" + + +def _as_str(value: object) -> str | None: + if value is None or value == "N/A": + return None + return str(value) diff --git a/tools/video_reporting.py b/tools/video_reporting.py new file mode 100644 index 0000000..d0a6c9a --- /dev/null +++ b/tools/video_reporting.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from datetime import timedelta, timezone + +from tools.avisynth_render import AvisynthClipInfo +from tools.console import light_blue, light_green, light_red +from tools.video_formatting import ( + audio_parts, + format_bitrate, + format_channels, + format_chroma, + format_frame_count, + format_framerate, + format_seconds, + format_size, + join_parts, + matrix_name, + rate_value, + video_parts, +) +from tools.video_inputs import VideoInput +from tools.video_outputs import output_begin_timestamp +from tools.video_probe import VideoProbe +from tools.video_timeline import OutputTimeline + + +def print_validation_summary( + *, + accepted: bool, + output_frame_count: int, + dropped_frame_count: int, + timeline: OutputTimeline, + clip_info: AvisynthClipInfo | None, + probe: VideoProbe, +) -> None: + if not accepted: + print(" not valid") + return + for line in format_validation_summary_lines( + output_frame_count=output_frame_count, + dropped_frame_count=dropped_frame_count, + timeline=timeline, + clip_info=clip_info, + probe=probe, + ): + print(f" {line}") + if clip_info is not None: + duration = clip_info.duration_seconds + if duration is not None and abs(duration - timeline.duration_seconds) > 0.05: + print( + " warning: Avisynth playback duration differs from source-frame " + "timeline; encoding will use the validated timeline where supported" + ) + + +def format_validation_summary_lines( + *, + output_frame_count: int, + dropped_frame_count: int, + timeline: OutputTimeline, + clip_info: AvisynthClipInfo | None, + probe: VideoProbe, +) -> list[str]: + return [ + _validation_duration_line( + output_frame_count, + dropped_frame_count, + timeline, + probe, + ), + f"source span: {_format_source_span(timeline)}", + f"video: {_format_validation_video(clip_info, timeline, probe)}", + f"audio: {_format_validation_audio(clip_info, probe)}", + f"timestamp: {_format_validation_timestamp(timeline, probe)}", + ] + + +def _validation_duration_line( + output_frame_count: int, + dropped_frame_count: int, + timeline: OutputTimeline, + probe: VideoProbe, +) -> str: + if dropped_frame_count: + frame_text = ( + f"{output_frame_count} script frames, {dropped_frame_count} dropped, " + f"{len(timeline.frames)} final frames" + ) + else: + frame_text = f"{len(timeline.frames)} frames" + output = f"{timeline.duration_seconds:.3f}s, {frame_text}" + + source_frames = probe.stream_frame_count or probe.timing.frame_count + source_duration = probe.duration_seconds + duration_changed = ( + source_duration is not None + and abs(source_duration - timeline.duration_seconds) >= 0.0005 + ) + frames_changed = source_frames != len(timeline.frames) + if source_duration is not None and (duration_changed or frames_changed): + old_duration = f"{source_duration:.3f}s" + old_frames = f"{source_frames} frames" + output += ( + f" ({light_red(old_duration) if duration_changed else old_duration}, " + f"{light_red(old_frames) if frames_changed else old_frames})" + ) + return f"duration: {output}" + + +def _format_source_span(timeline: OutputTimeline) -> str: + frames = sorted(set(timeline.consumed_source_frames)) + if not frames: + return "none" + spans: list[tuple[int, int]] = [] + start = previous = frames[0] + for frame in frames[1:]: + if frame != previous + 1: + spans.append((start, previous)) + start = frame + previous = frame + spans.append((start, previous)) + + if len(spans) == 1: + selection = _format_frame_range(*spans[0]) + elif len(spans) <= 4: + selection = ", ".join(_format_frame_range(*span) for span in spans) + else: + selection = ( + f"{frames[0]} -> {frames[-1]}, {len(frames)} source frames " + f"in {len(spans)} spans" + ) + return f"{selection} of {timeline.source_frame_count}" + + +def _format_frame_range(start: int, end: int) -> str: + return str(start) if start == end else f"{start} -> {end}" + + +def _format_validation_video( + clip_info: AvisynthClipInfo | None, + timeline: OutputTimeline, + probe: VideoProbe, +) -> str: + output_width = clip_info.width if clip_info else probe.width + output_height = clip_info.height if clip_info else probe.height + output_chroma = format_chroma(clip_info.chroma if clip_info else probe.pixel_format) + output_depth = clip_info.bit_depth if clip_info else probe.bit_depth + output_matrix = matrix_name(timeline.matrix) + output_fps = len(timeline.frames) / timeline.duration_seconds + output = join_parts([ + f"{timeline.timing_kind.upper()} {output_fps:.3f} fps", + *video_parts( + width=output_width, + height=output_height, + pixel_format=clip_info.chroma if clip_info else probe.pixel_format, + bit_depth=output_depth, + matrix=output_matrix, + ), + ]) + + source = [probe.codec or "unknown codec"] + source_fps = rate_value(probe.avg_frame_rate) + if source_fps is not None: + source_rate = f"{source_fps:.3f} fps" + if probe.timing.timing_kind != timeline.timing_kind: + source_rate = f"{probe.timing.timing_kind.upper()} {source_rate}" + source_rate = light_red(source_rate) + elif f"{source_fps:.3f}" != f"{output_fps:.3f}": + source_rate = light_red(source_rate) + source.append(source_rate) + if (probe.width, probe.height) != (output_width, output_height): + source.append(light_red(format_size(probe.width, probe.height) or "unknown size")) + source_chroma = format_chroma(probe.pixel_format) + if source_chroma and source_chroma != output_chroma: + source.append(light_red(source_chroma)) + if probe.bit_depth and probe.bit_depth != output_depth: + source.append(light_red(f"{probe.bit_depth}-bit")) + source_matrix = probe.color_matrix or "unknown" + if source_matrix != output_matrix: + source.append(light_red(source_matrix)) + source.append(format_bitrate(probe.video_bit_rate)) + return f"{output} ({join_parts(source)})" + + +def _format_validation_audio( + clip_info: AvisynthClipInfo | None, + probe: VideoProbe, +) -> str: + output_has_audio = bool(clip_info and clip_info.has_audio) + if output_has_audio: + output = join_parts( + audio_parts( + channels=clip_info.audio_channels, + sample_rate=clip_info.audio_rate, + ) + ) + else: + output = "none" + + if not probe.audio_streams: + return f"{output} (none)" if output_has_audio else output + source_stream = probe.audio_streams[0] + source = [source_stream.codec or "unknown codec"] + if not output_has_audio or source_stream.channels != clip_info.audio_channels: + source.append(light_red(format_channels(source_stream.channels) or "unknown channels")) + if not output_has_audio or source_stream.sample_rate != clip_info.audio_rate: + source.append(light_red( + f"{source_stream.sample_rate} Hz" + if source_stream.sample_rate is not None + else "unknown sample rate" + )) + source.append(format_bitrate(source_stream.bit_rate)) + return f"{output} ({join_parts(source)})" + + +def _format_validation_timestamp( + timeline: OutputTimeline, + probe: VideoProbe, +) -> str: + if probe.filename_begin is None: + return "unknown" + display_timezone = probe.source_timezone or timezone.utc + try: + output = output_begin_timestamp(probe, timeline, display_timezone) + except RuntimeError: + return "unknown" + output = _round_datetime_to_second(output) + source = _round_datetime_to_second(probe.filename_begin.astimezone(display_timezone)) + result = _format_datetime_without_zone(output) + if output != source: + old: list[str] = [] + if output.date() != source.date(): + old.append(light_red(source.date().isoformat())) + if output.time() != source.time(): + old.append(light_red(source.time().isoformat())) + result += f" ({' '.join(old)})" + return result + + +def _format_datetime_without_zone(value) -> str: + return f"{light_green(value.date().isoformat())} {light_blue(value.time().isoformat())}" + + +def print_probe_summary(inputs: list[VideoInput], probes: list[VideoProbe]) -> None: + by_path = {probe.path.resolve(): probe for probe in probes} + print("\nProbe summary:") + for item in inputs: + probe = by_path[item.path.resolve()] + print(f"\n{item.collapsed_relative}") + print( + f" duration: {format_seconds(probe.duration_seconds)}, " + f"{format_frame_count(probe.stream_frame_count, probe.timing.frame_count)}" + ) + print(f" video: {_format_probe_video_line(probe)}") + if probe.audio_streams: + for index, stream in enumerate(probe.audio_streams, start=1): + print(f" audio {index}: {_format_audio_stream(stream)}") + else: + print(" audio: none") + print(f" framerate: {_format_probe_framerate(probe)}") + if probe.timing.timing_kind == "vfr": + print(_format_vfr_timing(probe)) + print(f" timestamp: {_format_probe_timestamp(probe)}") + if probe.metadata_begin_tag is not None: + display_timezone = probe.source_timezone or timezone.utc + print( + " possible beginning tag: " + f"{_format_datetime(probe.metadata_begin_tag.astimezone(display_timezone))}" + ) + for warning in probe.warnings: + print(f" warning: {warning}") + + +def _format_probe_video_line(probe: VideoProbe) -> str: + return join_parts( + video_parts( + codec=probe.codec or "unknown codec", + width=probe.width, + height=probe.height, + pixel_format=probe.pixel_format, + bit_depth=probe.bit_depth, + matrix=probe.color_matrix or "unknown", + bit_rate=probe.video_bit_rate, + ) + ) + + +def _format_audio_stream(stream) -> str: + return join_parts( + audio_parts( + codec=stream.codec or "unknown codec", + channels=stream.channels, + sample_rate=stream.sample_rate, + bit_rate=stream.bit_rate, + ) + ) + + +def _format_probe_framerate(probe: VideoProbe) -> str: + return format_framerate(probe.timing.timing_kind, probe.avg_frame_rate) + + +def _format_probe_timestamp(probe: VideoProbe) -> str: + if probe.metadata_end is None: + return "unknown" + display_timezone = probe.source_timezone or timezone.utc + end = _round_datetime_to_second(probe.metadata_end.astimezone(display_timezone)) + if probe.filename_begin is None: + return _format_datetime(end) + begin = _round_datetime_to_second(probe.filename_begin.astimezone(display_timezone)) + begin_date = light_green(begin.date().isoformat()) + begin_time = light_blue(begin.time().isoformat()) + end_time = light_blue(end.time().isoformat()) + if begin.date() == end.date(): + span = f"{begin_date} {begin_time} - {end_time}" + else: + end_date = light_green(end.date().isoformat()) + span = f"{begin_date} {begin_time} - {end_date} {end_time}" + return f"{span} {light_red(_format_timezone(end))}" + + +def _format_vfr_timing(probe: VideoProbe) -> str: + timing = probe.timing + delta_line = ( + " " + f"{timing.unique_delta_count} rounded deltas (ms): " + f"{light_green(_format_ms(timing.min_delta_ms))} min, " + f"{light_green(_format_ms(timing.median_delta_ms))} median, " + f"{light_green(_format_ms(timing.max_delta_ms))} max" + ) + common = [ + f"{light_green(_format_ms(delta))} {count}x" + for delta, count in timing.common_deltas_ms + ] + if not common: + return delta_line + common_line = " common deltas (ms): " + ", ".join(common) + return "\n".join([delta_line, common_line]) + + +def _format_ms(value: float | None) -> str: + if value is None: + return "unknown" + return f"{value:g}" + + +def _format_datetime(value) -> str: + if value is None: + return "unknown" + rounded = value + date = rounded.date().isoformat() + time = rounded.timetz().isoformat() + if rounded.tzinfo is not None: + time, tz = _split_time_zone(time) + return f"{light_green(date)} {light_blue(time)} {light_red(tz)}" + return f"{light_green(date)} {light_blue(time)}" + + +def _format_timezone(value) -> str: + offset = value.strftime("%z") + return f"{offset[:3]}:{offset[3:]}" if offset else "" + + +def _round_datetime_to_second(value): + return (value + timedelta(microseconds=500_000)).replace(microsecond=0) + + +def _split_time_zone(value: str) -> tuple[str, str]: + if value.endswith("Z"): + return value[:-1], "Z" + for index in range(len(value) - 6, 0, -1): + if value[index] in "+-": + return value[:index], value[index:] + return value, "" diff --git a/tools/video_timeline.py b/tools/video_timeline.py new file mode 100644 index 0000000..05cadd4 --- /dev/null +++ b/tools/video_timeline.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import statistics +from dataclasses import dataclass + +from tools.avisynth_validate import FrameIdentity +from tools.video_probe import VideoProbe + + +@dataclass(frozen=True) +class OutputFrameTiming: + output_index: int + source_id: int + source_frame: int + start: float + duration: float + + +@dataclass(frozen=True) +class OutputTimeline: + frames: list[OutputFrameTiming] + audio_segments: list[tuple[float, float]] + consumed_source_frames: list[int] + duration_seconds: float + timing_kind: str + beginning_trimmed: bool + ending_trimmed: bool + dropped_frame_count: int + source_frame_count: int + matrix: int | None + + +def build_output_timeline( + identities: list[FrameIdentity], + *, + probe: VideoProbe, +) -> OutputTimeline: + source_durations = _source_frame_durations(probe) + kept_frames: list[OutputFrameTiming] = [] + audio_segments: list[tuple[float, float]] = [] + consumed_source_frames: list[int] = [] + matrices: set[int] = set() + dropped_count = 0 + leading_trimmed = False + last_consumed_source_frame: int | None = None + + for frame in sorted(identities, key=lambda item: item.output_frame): + if frame.source_id is None or frame.source_frame is None: + continue + if frame.source_frame >= len(source_durations): + raise RuntimeError( + f"source frame {frame.source_frame} is outside probed frame table " + f"for {probe.path}" + ) + + duration = source_durations[frame.source_frame] + if frame.drop_frame: + dropped_count += 1 + if kept_frames: + consumed_source_frames.append(frame.source_frame) + _append_audio_segment( + audio_segments, + start=probe.frame_timestamps[frame.source_frame], + duration=duration, + ) + previous = kept_frames[-1] + kept_frames[-1] = OutputFrameTiming( + output_index=previous.output_index, + source_id=previous.source_id, + source_frame=previous.source_frame, + start=previous.start, + duration=previous.duration + duration, + ) + last_consumed_source_frame = frame.source_frame + else: + leading_trimmed = True + continue + + consumed_source_frames.append(frame.source_frame) + if frame.matrix is not None: + matrices.add(frame.matrix) + _append_audio_segment( + audio_segments, + start=probe.frame_timestamps[frame.source_frame], + duration=duration, + ) + kept_frames.append( + OutputFrameTiming( + output_index=len(kept_frames), + source_id=frame.source_id, + source_frame=frame.source_frame, + start=probe.frame_timestamps[frame.source_frame], + duration=duration, + ) + ) + last_consumed_source_frame = frame.source_frame + + if not kept_frames: + raise RuntimeError(f"No kept output frames after validation for {probe.path}") + if len(matrices) > 1: + raise RuntimeError( + f"Output frames have mixed _Matrix values for {probe.path}: " + + ", ".join(str(value) for value in sorted(matrices)) + ) + + source_count = len(probe.frame_timestamps) + beginning_trimmed = leading_trimmed or kept_frames[0].source_frame > 0 + if last_consumed_source_frame is None: + last_consumed_source_frame = kept_frames[-1].source_frame + ending_trimmed = last_consumed_source_frame < source_count - 1 + durations = [frame.duration for frame in kept_frames] + return OutputTimeline( + frames=kept_frames, + audio_segments=audio_segments, + consumed_source_frames=consumed_source_frames, + duration_seconds=sum(durations), + timing_kind=_classify_durations(durations), + beginning_trimmed=beginning_trimmed, + ending_trimmed=ending_trimmed, + dropped_frame_count=dropped_count, + source_frame_count=source_count, + matrix=next(iter(matrices)) if matrices else None, + ) + + +def _append_audio_segment( + segments: list[tuple[float, float]], + *, + start: float, + duration: float, +) -> None: + if not segments: + segments.append((start, duration)) + return + + previous_start, previous_duration = segments[-1] + previous_end = previous_start + previous_duration + if abs(previous_end - start) <= 0.001: + segments[-1] = (previous_start, previous_duration + duration) + else: + segments.append((start, duration)) + + +def _source_frame_durations(probe: VideoProbe) -> list[float]: + timestamps = probe.frame_timestamps + if not timestamps: + raise RuntimeError(f"No probed frame timestamps for {probe.path}") + + durations: list[float] = [] + for current, next_timestamp in zip(timestamps, timestamps[1:]): + durations.append(max(0.0, next_timestamp - current)) + + fallback = statistics.median(durations) if durations else 0.0 + if probe.duration_seconds is not None and len(timestamps) > 1: + last_duration = probe.duration_seconds - timestamps[-1] + if last_duration <= 0: + last_duration = fallback + else: + last_duration = fallback + durations.append(max(0.0, last_duration)) + return durations + + +def _classify_durations(durations: list[float]) -> str: + if len(durations) < 2: + return "unknown" + median = statistics.median(durations) + max_deviation = max(abs(duration - median) for duration in durations) + return "vfr" if max_deviation > 0.002 else "cfr" diff --git a/tools/video_timestamp_encode.py b/tools/video_timestamp_encode.py new file mode 100644 index 0000000..2afd311 --- /dev/null +++ b/tools/video_timestamp_encode.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +from tools.console import PipelineProgressView, ProgressView +from tools.video_encode_output import ( + AudioEncodeOptions, + VideoCodecOptions, + VideoEncodeResult, + add_audio_input_options, + add_audio_options, + encode_video_only_y4m, + pixel_format_bit_depth, + read_ffmpeg_progress, + run_y4m_encoder, +) + + +def encode_video_with_timestamps( + *, + y4m_command: list[str], + ffmpeg: Path, + mkvmerge: Path, + x264: Path | None, + script: Path, + frame_durations: list[float], + audio_source: Path, + output: Path, + options: VideoCodecOptions, + audio_options: AudioEncodeOptions, + audio_start: float, + audio_duration: float | None, + audio_segments: list[tuple[float, float]] | None, + audio_tempo: float, + audio_sample_rate: int | None, + source_has_audio: bool, + allow_audio_copy: bool, + pixel_format: str, + colorspace: str | None, + frame_count: int | None = None, + progress_label: str | None = None, +) -> VideoEncodeResult: + if not frame_durations: + raise RuntimeError("Cannot encode VFR output with no frame durations") + + output.parent.mkdir(parents=True, exist_ok=True) + # Network shares may allow the final output but deny creating temporary folders. + with tempfile.TemporaryDirectory(prefix="mbt-vfr-") as temp_dir: + temp_root = Path(temp_dir) + timecodes = temp_root / "frames.timecodes.txt" + write_timecode_v2(timecodes, frame_durations) + intermediate = temp_root / ( + "encoded.h264" if options.codec == "libx264" else "encoded.mkv" + ) + if options.codec == "libx264": + if x264 is None: + raise RuntimeError("x264 CLI is required for timestamp-aware x264 encoding") + renderer_returncode, encoder_log = encode_x264_y4m( + y4m_command=y4m_command, + x264=x264, + script=script, + timecodes=timecodes, + output=intermediate, + options=options, + pixel_format=pixel_format, + colorspace=colorspace, + frame_count=len(frame_durations), + progress_label=progress_label, + ) + elif options.codec == "libx265": + result = encode_video_only_y4m( + y4m_command=y4m_command, + ffmpeg=ffmpeg, + script=script, + audio_source=None, + output=intermediate, + options=options, + audio_options=AudioEncodeOptions(mode="none"), + pixel_format=pixel_format, + colorspace=colorspace, + frame_count=len(frame_durations), + progress_label=progress_label, + ) + renderer_returncode = result.renderer_returncode + encoder_log = result.encoder_log + else: + raise RuntimeError(f"Unsupported timestamp-aware codec: {options.codec}") + + timed_video = temp_root / "timed.mkv" + apply_video_timecodes( + mkvmerge=mkvmerge, + source=intermediate, + timecodes=timecodes, + output=timed_video, + ) + ffmpeg_returncode = mux_timed_video_with_audio( + ffmpeg=ffmpeg, + timed_video=timed_video, + audio_source=audio_source, + output=output, + audio_options=audio_options, + audio_start=audio_start, + audio_duration=audio_duration, + audio_segments=audio_segments, + audio_tempo=audio_tempo, + audio_sample_rate=audio_sample_rate, + source_has_audio=source_has_audio, + allow_audio_copy=allow_audio_copy, + frame_count=len(frame_durations), + progress_label=progress_label, + ) + + return VideoEncodeResult( + script=script, + output=output, + renderer_returncode=renderer_returncode, + ffmpeg_returncode=ffmpeg_returncode, + encoder_log=encoder_log, + ) + + +def write_timecode_v2(path: Path, frame_durations: list[float]) -> None: + timestamps = [0.0] + for duration in frame_durations: + if duration <= 0: + raise RuntimeError(f"Invalid video frame duration: {duration}") + timestamps.append(timestamps[-1] + duration) + lines = ["# timecode format v2"] + lines.extend(f"{timestamp * 1000:.9f}" for timestamp in timestamps) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def encode_x264_y4m( + *, + y4m_command: list[str], + x264: Path, + script: Path, + timecodes: Path, + output: Path, + options: VideoCodecOptions, + pixel_format: str, + colorspace: str | None, + frame_count: int | None, + progress_label: str | None, +) -> tuple[int, str]: + run = run_y4m_encoder( + y4m_command=y4m_command, + script=script, + encoder_command=x264_command( + x264=x264, + timecodes=timecodes, + output=output, + options=options, + pixel_format=pixel_format, + colorspace=colorspace, + ), + frame_count=frame_count, + progress_label=progress_label or script.name, + read_progress=read_x264_progress, + ) + if run.encoder_returncode != 0: + details = run.encoder_log + if run.renderer_returncode != 0: + details += "\nAvisynth runner:\n" + run.renderer_log + raise RuntimeError( + f"x264 failed for {script} with exit {run.encoder_returncode}:\n" + + details.strip() + ) + if run.renderer_returncode != 0: + raise RuntimeError( + f"Avisynth runner failed for {script} with exit {run.renderer_returncode}:\n" + + run.renderer_log + ) + return run.renderer_returncode, run.encoder_log + + +def x264_command( + *, + x264: Path, + timecodes: Path, + output: Path, + options: VideoCodecOptions, + pixel_format: str, + colorspace: str | None, +) -> list[str]: + command = [ + str(x264), + "--demuxer", + "y4m", + "--tcfile-in", + str(timecodes), + "--crf", + str(options.crf), + "--preset", + options.preset, + "--output-depth", + str(pixel_format_bit_depth(pixel_format)), + "--output-csp", + _x264_output_csp(pixel_format), + "--verbose", + "--no-progress", + ] + if options.profile: + command.extend(["--profile", options.profile]) + if options.level: + command.extend(["--level", options.level]) + if options.threads is not None: + command.extend(["--threads", str(options.threads)]) + if colorspace: + command.extend(["--colormatrix", colorspace]) + command.extend(["--output", str(output), "-"]) + return command + + +def _x264_output_csp(pixel_format: str) -> str: + if "444" in pixel_format: + return "i444" + if "422" in pixel_format: + return "i422" + return "i420" + + +def apply_video_timecodes( + *, + mkvmerge: Path, + source: Path, + timecodes: Path, + output: Path, +) -> None: + completed = subprocess.run( + [ + str(mkvmerge), + "--timestamp-scale", + "1000", + "--no-date", + "-o", + str(output), + "--timestamps", + f"0:{timecodes}", + str(source), + ], + capture_output=True, + text=True, + ) + if completed.returncode != 0: + details = (completed.stderr or completed.stdout).strip() + raise RuntimeError( + f"mkvmerge failed to apply video timestamps with exit " + f"{completed.returncode}:\n{details}" + ) + + +def mux_timed_video_with_audio( + *, + ffmpeg: Path, + timed_video: Path, + audio_source: Path, + output: Path, + audio_options: AudioEncodeOptions, + audio_start: float, + audio_duration: float | None, + audio_segments: list[tuple[float, float]] | None, + audio_tempo: float, + audio_sample_rate: int | None, + source_has_audio: bool, + allow_audio_copy: bool, + frame_count: int | None, + progress_label: str | None, +) -> int: + command = [ + str(ffmpeg), + "-y", + "-hide_banner", + "-loglevel", + "error", + "-nostats", + "-progress", + "pipe:2", + "-i", + str(timed_video), + ] + if audio_options.mode != "none" and source_has_audio: + add_audio_input_options( + command, + audio_options=audio_options, + audio_start=audio_start, + audio_duration=audio_duration, + allow_audio_copy=allow_audio_copy, + ) + command.extend(["-i", str(audio_source)]) + + command.extend(["-map", "0:v:0", "-c:v", "copy"]) + if output.suffix.lower() == ".mp4": + command.extend(["-video_track_timescale", "90000"]) + add_audio_options( + command, + audio_options=audio_options, + audio_start=audio_start, + audio_duration=audio_duration, + audio_segments=audio_segments, + audio_tempo=audio_tempo, + audio_sample_rate=audio_sample_rate, + source_has_audio=source_has_audio, + allow_audio_copy=allow_audio_copy, + ) + command.append(str(output)) + + process = subprocess.Popen( + command, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + ffmpeg_stderr = read_ffmpeg_progress( + process, + total_frames=frame_count, + label=f"Muxing {progress_label or output.name}", + ) + if process.returncode != 0: + raise RuntimeError( + f"FFmpeg failed while muxing timestamped output {output} with exit " + f"{process.returncode}:\n{ffmpeg_stderr}" + ) + return process.returncode + + +def read_x264_progress( + process: subprocess.Popen, + *, + total_frames: int | None, + label: str, + pipeline_progress: PipelineProgressView | None = None, +) -> str: + assert process.stderr is not None + messages: list[str] = [] + progress = ( + ProgressView(total_frames or 0, label, embedded_percent=True, show_rate=True) + if total_frames and sys.stdout.isatty() and pipeline_progress is None + else None + ) + if progress: + progress.update(0) + for line in process.stderr: + match = re.search(r"x264 \[debug\]: frame=\s*(\d+)", line) + if match: + if progress: + progress.update(int(match.group(1)) + 1) + elif pipeline_progress is not None: + pipeline_progress.update_encoding(int(match.group(1)) + 1) + else: + messages.append(line.rstrip()) + process.wait() + if progress: + progress.finish(keep=True) + return "\n".join(messages) diff --git a/tools/video_validation_workflow.py b/tools/video_validation_workflow.py new file mode 100644 index 0000000..456a667 --- /dev/null +++ b/tools/video_validation_workflow.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from tools.console import clear_screen +from tools.avisynth_runner import AvisynthRunnerSet +from tools.avisynth_render import AvisynthClipInfo, run_validation_script +from tools.avisynth_workspace import ( + helper_script_path, + hidden_source_script_path, + validation_csv_path, + validation_script_path, + visible_script_path, +) +from tools.video_inputs import VideoInput +from tools.video_probe import VideoProbe +from tools.video_reporting import print_validation_summary +from tools.video_timeline import OutputTimeline, build_output_timeline + + +def run_validations( + avs_runners: AvisynthRunnerSet, + root: Path, + inputs: list[VideoInput], + probes_by_path: dict[Path, VideoProbe], + *, + blank_source: bool, + ffms2_plugin_path: Path | None, +) -> tuple[dict[Path, OutputTimeline], dict[Path, AvisynthClipInfo]]: + print(f"\nAvisynth runner: {avs_runners.default}") + print( + "Validation source: " + + ("blank frames (fast)" if blank_source else "real frames (slow)") + ) + rejected: list[Path] = [] + timelines: dict[Path, OutputTimeline] = {} + clip_infos: dict[Path, AvisynthClipInfo] = {} + summaries: list[tuple[VideoInput, bool, int, int, OutputTimeline | None, AvisynthClipInfo | None, VideoProbe, list[str]]] = [] + for source_id, item in enumerate(inputs): + probe = probes_by_path[item.path.resolve()] + visible_script = visible_script_path(root, item) + avs_runner = avs_runners.for_script(visible_script) + progress_label = ( + f"Validating script {source_id + 1}/{len(inputs)}: " + f"{item.collapsed_relative}" + ) + if not sys.stdout.isatty(): + print(f"\n{progress_label}") + if avs_runner != avs_runners.default: + print(f" runner: {avs_runner}") + csv_path = validation_csv_path(root, item) + try: + run = run_validation_script( + renderer_command=[ + str(avs_runner), + "--validate" if blank_source else "--validate-real", + ], + script=validation_script_path(root, item), + csv_path=csv_path, + expected_source_ids={source_id}, + progress_label=progress_label, + ) + except RuntimeError as exc: + diagnostics = _diagnostics_for_error( + avs_runner, + root, + item, + ffms2_plugin_path, + exc, + ) + if diagnostics: + raise RuntimeError(f"{exc}\n\n{diagnostics}") from exc + raise + _remove_validation_artifacts(csv_path) + result = run.result + if run.recovered_from_renderer_error: + print( + " warning: Avisynth runner exited with code " + f"{run.renderer_returncode} after writing a complete frame table; " + "continuing validation" + ) + issues = list(result.issues) + unsupported_depth = run.clip_info is not None and run.clip_info.bit_depth > 12 + if unsupported_depth: + issues.append( + f"Avisynth output is {run.clip_info.bit_depth}-bit; maximum supported output is 12-bit" + ) + if issues: + summaries.append((item, False, result.frame_count, result.dropped_frame_count, None, run.clip_info, probe, issues)) + if result.issues: + rejected.append(item.collapsed_relative) + continue + + timeline = build_output_timeline(run.frames, probe=probe) + timelines[item.path.resolve()] = timeline + if run.clip_info is not None: + clip_infos[item.path.resolve()] = run.clip_info + summaries.append((item, result.accepted, result.frame_count, result.dropped_frame_count, timeline, run.clip_info, probe, [])) + + clear_screen() + for item, accepted, frame_count, dropped_count, timeline, clip_info, probe, issues in summaries: + print(f"\n{item.collapsed_relative}") + if issues: + print(" not valid") + for issue in issues: + print(f" issue: {issue}") + continue + assert timeline is not None + print_validation_summary( + accepted=accepted, + output_frame_count=frame_count, + dropped_frame_count=dropped_count, + timeline=timeline, + clip_info=clip_info, + probe=probe, + ) + + if rejected: + raise RuntimeError( + "Rejected Avisynth output identity for: " + + ", ".join(str(path) for path in rejected) + ) + return timelines, clip_infos + + +def _diagnostics_for_error( + runner: Path, + root: Path, + item: VideoInput, + ffms2_plugin_path: Path | None, + error: RuntimeError, +) -> str: + if "exit code 3221225477" not in str(error): + return "" + try: + return _run_import_diagnostics(runner, root, item, ffms2_plugin_path) + except Exception as exc: + return f"Avisynth import diagnostics failed: {exc}" + + +def _run_import_diagnostics( + runner: Path, + root: Path, + item: VideoInput, + ffms2_plugin_path: Path | None, +) -> str: + source_dir = validation_script_path(root, item).parent + stem = item.collapsed_relative.name + helper = helper_script_path() + hidden_source = hidden_source_script_path(root, item) + visible = visible_script_path(root, item) + plugin_path = str(ffms2_plugin_path) if ffms2_plugin_path else None + source_path = str(item.path) + + cases: list[tuple[str, str]] = [ + ( + "blank", + 'BlankClip(length=1, width=16, height=16, pixel_type="YV12")\n', + ), + ( + "import-helper", + f'Import("{_avs_path(str(helper.resolve()))}")\n' + 'BlankClip(length=1, width=16, height=16, pixel_type="YV12")\n', + ), + ] + if plugin_path: + cases.append( + ( + "load-ffms2", + f'LoadPlugin("{_avs_path(str(Path(plugin_path).resolve()))}")\n' + 'BlankClip(length=1, width=16, height=16, pixel_type="YV12")\n', + ) + ) + cases.extend( + [ + ( + "ffvideo", + _optional_load_plugin(plugin_path) + + f'FFVideoSource("{_avs_path(str(Path(source_path).resolve()))}", ' + + f'cachefile="{_avs_path(str((source_dir / (stem + ".diag.ffindex")).resolve()))}")\n', + ), + ( + "hidden-source", + "mbt_video_only = true\n" + f'Import("{_avs_path(str(hidden_source.resolve()))}")\n' + "last\n", + ), + ( + "editable", + "mbt_video_only = true\n" + f'Import("{_avs_path(str(visible.resolve()))}")\n' + "last\n", + ), + ] + ) + + lines = [f"Avisynth import diagnostics with {runner}:"] + for name, script_text in cases: + script = source_dir / f"{stem}.diag.{name}.avs" + csv = source_dir / f"{stem}.diag.{name}.csv" + log = Path(f"{csv}.runner.log") + for path in (csv, log): + if path.exists(): + path.unlink() + script.write_text(script_text, encoding="utf-8") + completed = subprocess.run( + [str(runner), "--validate", str(script), str(csv)], + capture_output=True, + text=True, + ) + lines.append(f"- {name}: exit {completed.returncode}, csv={'yes' if csv.exists() else 'no'}") + if completed.stderr.strip(): + lines.append(_indent("stderr: " + completed.stderr.strip())) + if completed.stdout.strip(): + lines.append(_indent("stdout: " + _tail(completed.stdout.strip()))) + if log.exists(): + lines.append(_indent("runner log:\n" + _tail(log.read_text(encoding="utf-8", errors="replace").strip()))) + return "\n".join(lines) + + +def _remove_validation_artifacts(csv_path: Path) -> None: + for path in (csv_path, Path(f"{csv_path}.runner.log")): + path.unlink(missing_ok=True) + + +def _optional_load_plugin(plugin_path: str | None) -> str: + if not plugin_path: + return "" + return f'LoadPlugin("{_avs_path(str(Path(plugin_path).resolve()))}")\n' + + +def _avs_path(path: str) -> str: + return path.replace("\\", "/").replace('"', '\\"') + + +def _indent(text: str) -> str: + return "\n".join(" " + line for line in text.splitlines()) + + +def _tail(text: str, max_lines: int = 12) -> str: + lines = text.splitlines() + if len(lines) <= max_lines: + return text + return "\n".join(lines[-max_lines:]) diff --git a/unit_tests/test_exiftool.py b/unit_tests/test_exiftool.py new file mode 100644 index 0000000..d660e67 --- /dev/null +++ b/unit_tests/test_exiftool.py @@ -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() diff --git a/unit_tests/test_video_e2e.py b/unit_tests/test_video_e2e.py new file mode 100644 index 0000000..19d9d3f --- /dev/null +++ b/unit_tests/test_video_e2e.py @@ -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() diff --git a/unit_tests/test_video_logic.py b/unit_tests/test_video_logic.py new file mode 100644 index 0000000..842b3a6 --- /dev/null +++ b/unit_tests/test_video_logic.py @@ -0,0 +1,1034 @@ +from __future__ import annotations + +import unittest +import tempfile +from contextlib import redirect_stdout +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_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, + add_video_threads, + atempo_filters, + choose_video_pixel_format, + encoder_statistics_lines, + read_ffmpeg_progress, + speed_change_filters, +) +from tools.video_timestamp_encode import write_timecode_v2, x264_command +from tools.video_encode_plan import ( + missing_source_frames_between_kept, + normal_deleted_frame_count, + 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_options import OutputNamingOptions +from tools.video_outputs import output_begin_timestamp, planned_output_path +from tools.video_batch_encode import ( + _default_output_timezone, + _default_answer, + _encoding_steps, + _parse_answer, + _question_for_key, +) +from tools.video_probe import AudioStreamProbe, VideoProbe, _video_timezone, 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 + + +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=[], + ) + + +class VideoCodecConstraintTests(unittest.TestCase): + 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_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 WorkspaceSummaryTests(unittest.TestCase): + 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: + command = x264_command( + 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) + 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_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_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_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_timestamp_naming_off_skips_timezone_step(self) -> None: + self.assertNotIn( + "timezone", + _encoding_steps({"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) + + self.assertEqual(steps[steps.index("level") + 1], "threads") + self.assertEqual(_parse_answer("threads", "auto", {}, []), "auto") + self.assertEqual(_parse_answer("threads", "2", {}, []), 2) + with self.assertRaisesRegex(ValueError, "positive integer"): + _parse_answer("threads", "0", {}, []) + + 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) + + def test_container_question_lists_available_options(self) -> None: + question = _question_for_key("container", {}, []) + + self.assertIn("(mp4/mkv)", question) + + def test_preset_question_lists_available_options(self) -> None: + question = _question_for_key("preset", {}, []) + + 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(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: + 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(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)]) + + 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_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_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.assertNotIn('Defined("mbt_video_only")', text) + + 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() diff --git a/video_encode.py b/video_encode.py new file mode 100644 index 0000000..c950286 --- /dev/null +++ b/video_encode.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +""" +Prepare, validate, and encode Avisynth-edited video outputs. + +Drag files or directories onto this script, or run: + python3 video_encode.py path/to/file/or/directory [...] + +The script collects supported videos, probes timing and metadata, creates an +Avisynth workspace, validates frame identity metadata, and can encode supported +outputs. Source videos are only modified if the final disposition explicitly +replaces or moves them. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +from tools.avisynth_render import AvisynthClipInfo +from tools.avisynth_workspace import ( + existing_visible_scripts, + validation_script_path, + visible_script_path, + workspace_root, + write_workspace, +) +from tools.console import ProgressView, pause_if_interactive, prompt_input, prompt_yes_no +from tools.exiftool import require_exiftool, run_exiftool_json +from tools.filesystem import determine_working_dir +from tools.video_batch_encode import encode_validated_video_only +from tools.video_inputs import VideoInput, collect_video_inputs +from tools.video_options import ( + ask_avisynth_runners, + ask_encode_video_only, + ask_ffms2_plugin_path, +) +from tools.video_probe import probe_video, require_tool +from tools.video_reporting import print_probe_summary +from tools.video_timeline import OutputTimeline +from tools.video_validation_workflow import run_validations + + +def run(argv: list[str]) -> int: + if len(argv) < 2: + print("Usage: python3 video_encode.py path/to/file/or/directory [...]") + return 2 + + try: + working_dir = determine_working_dir(argv[1:]) + inputs = collect_video_inputs(argv[1:]) + except RuntimeError as exc: + print(exc) + return 1 + + if not inputs: + print("No supported video files found.") + return 1 + + try: + ffprobe = require_tool("ffprobe") + exiftool = require_exiftool() + except RuntimeError as exc: + print(exc) + return 1 + + print(f"Working directory: {working_dir}") + print(f"Found {len(inputs)} supported video file(s).") + print("\nCollapsed drag tree:") + for item in inputs: + print(f" - {item.collapsed_relative} -> {item.path}") + + try: + progress = ProgressView(len(inputs), "Reading metadata and frame timing") + probes = [] + for index, item in enumerate(inputs, start=1): + metadata_items = run_exiftool_json( + exiftool, + [item.path], + quicktime_utc=False, + ) + metadata = metadata_items[0] if metadata_items else {} + probes.append(probe_video(ffprobe, item.path, metadata)) + progress.update(index) + progress.finish() + except (subprocess.CalledProcessError, json.JSONDecodeError, RuntimeError) as exc: + print(f"Failed to probe videos: {exc}") + return 1 + + print_probe_summary(inputs, probes) + probes_by_path = {probe.path.resolve(): probe for probe in probes} + + try: + ffms2_plugin_path = ask_ffms2_plugin_path() + except RuntimeError as exc: + print(exc) + return 1 + + root = workspace_root(Path(__file__)) + try: + delete_stale_visible_scripts(root, inputs) + except RuntimeError as exc: + print(exc) + return 1 + + overwrite_visible = False + existing_scripts = existing_visible_scripts(root, inputs) + if existing_scripts: + print("\nExisting editable Avisynth scripts:") + for script in existing_scripts: + print(f" - {script}") + if sys.stdin.isatty(): + try: + overwrite_visible = prompt_yes_no( + "Overwrite existing editable .avs scripts?", default=False + ) + except RuntimeError as exc: + print(exc) + return 1 + if not overwrite_visible: + print("Keeping existing editable .avs scripts.") + else: + print("Keeping existing editable .avs scripts.") + + try: + written, skipped_visible = write_workspace( + root=root, + items=inputs, + probes_by_path=probes_by_path, + overwrite_visible=overwrite_visible, + ffms2_plugin_path=ffms2_plugin_path, + ) + except (OSError, RuntimeError) as exc: + print(f"Failed to create workspace: {exc}") + return 1 + + try: + avs_runners = ask_avisynth_runners() + if avs_runners is not None and skipped_visible: + invalid_scripts = preflight_kept_visible_scripts( + avs_runners=avs_runners, + root=root, + inputs=inputs, + skipped_visible=skipped_visible, + ) + if invalid_scripts: + if sys.stdin.isatty(): + overwrite_invalid = prompt_yes_no( + "Overwrite invalid kept editable .avs scripts?", + default=True, + ) + else: + overwrite_invalid = False + if overwrite_invalid: + try: + rewritten, skipped_visible = write_workspace( + root=root, + items=inputs, + probes_by_path=probes_by_path, + overwrite_visible=set(invalid_scripts), + ffms2_plugin_path=ffms2_plugin_path, + ) + except (OSError, RuntimeError) as exc: + print(f"Failed to recreate workspace: {exc}") + return 1 + written.extend(rewritten) + else: + raise RuntimeError( + "Kept editable scripts are invalid and were not overwritten." + ) + except RuntimeError as exc: + print(exc) + return 1 + + print_workspace_summary(root, inputs, written, skipped_visible) + + validation_mode = wait_for_script_edits() + if validation_mode is None: + return 0 + + try: + timelines: dict[Path, OutputTimeline] = {} + clip_infos: dict[Path, AvisynthClipInfo] = {} + if avs_runners is not None: + timelines, clip_infos = run_validations( + avs_runners, + root, + inputs, + probes_by_path, + blank_source=validation_mode == "fast", + ffms2_plugin_path=ffms2_plugin_path, + ) + else: + print("\nValidation: skipped; no Avisynth runner path provided.") + if timelines: + if sys.stdin.isatty() and "MBT_ENCODE_VIDEO" not in os.environ: + encode_validated_video_only( + root, + inputs, + timelines, + clip_infos, + probes_by_path, + exiftool, + avs_runners, + ffprobe, + ) + elif ask_encode_video_only(): + encode_validated_video_only( + root, + inputs, + timelines, + clip_infos, + probes_by_path, + exiftool, + avs_runners, + ffprobe, + ) + except (RuntimeError, subprocess.CalledProcessError) as exc: + print(f"\nVideo workflow failed: {exc}") + if isinstance(exc, subprocess.CalledProcessError): + if exc.stdout: + print(exc.stdout) + if exc.stderr: + print(exc.stderr) + return 1 + + return 0 + + +def print_workspace_summary( + root: Path, + inputs: list[VideoInput], + written: list[Path], + skipped_visible: list[Path], +) -> None: + print("\nWorkspace:") + print(f" root: {root}") + print(f" wrote {len(written)} file(s)") + existing = {script.resolve() for script in skipped_visible} + new_scripts = [ + visible_script_path(root, item) + for item in inputs + if visible_script_path(root, item).resolve() not in existing + ] + if skipped_visible: + print(" existing editable scripts:") + for script in skipped_visible: + print(f" - {script}") + if new_scripts: + heading = "new editable scripts:" if skipped_visible else "editable scripts:" + print(f" {heading}") + for script in new_scripts: + print(f" - {script}") + + +def delete_stale_visible_scripts(root: Path, inputs: list[VideoInput]) -> None: + stale = stale_visible_scripts(root, inputs) + if not stale: + return + print("\nOld editable Avisynth scripts not used by this batch:") + for script in stale: + print(f" - {script}") + if sys.stdin.isatty(): + delete = prompt_yes_no("Delete old editable .avs scripts?", default=True) + else: + delete = False + if not delete: + return + for script in stale: + script.unlink() + + +def stale_visible_scripts(root: Path, inputs: list[VideoInput]) -> list[Path]: + if not root.exists(): + return [] + current = {visible_script_path(root, item).resolve() for item in inputs} + stale: list[Path] = [] + for script in root.rglob("*.avs"): + relative = script.relative_to(root) + if relative.parts and relative.parts[0] in {".source", "edit"}: + continue + if script.resolve() not in current: + stale.append(script) + return sorted(stale) + + +def preflight_kept_visible_scripts( + *, + avs_runners, + root: Path, + inputs: list[VideoInput], + skipped_visible: list[Path], +) -> list[Path]: + skipped = {path.resolve() for path in skipped_visible} + invalid: list[Path] = [] + for source_id, item in enumerate(inputs): + visible = visible_script_path(root, item) + if visible.resolve() not in skipped: + continue + runner = avs_runners.for_script(visible) + validation_script = validation_script_path(root, item) + completed = subprocess.run( + [ + str(runner), + "--check-source", + str(source_id), + str(validation_script), + ], + capture_output=True, + text=True, + ) + if completed.returncode == 0: + continue + invalid.append(visible) + reason = (completed.stderr or completed.stdout).strip() or ( + f"runner exited with code {completed.returncode}" + ) + print(f" invalid kept script: {visible}") + print(f" {reason}") + return invalid + + +def wait_for_script_edits() -> str | None: + if not sys.stdin.isatty(): + return "fast" + + while True: + answer = prompt_input( + "\nEdit the .avs scripts now if needed. Press Enter for fast validation, " + "r for real-frame validation, or q to stop after workspace creation: " + ).strip().lower() + if not answer or answer in {"f", "fast"}: + return "fast" + if answer in {"r", "real", "slow"}: + return "real" + if answer in {"q", "quit", "exit", "stop"}: + return None + print("Please press Enter, type r for real-frame validation, or type q to stop.") + + +def main(argv: list[str]) -> int: + try: + return run(argv) + except Exception as exc: + print(f"\nVideo workflow failed unexpectedly ({type(exc).__name__}): {exc}") + return 1 + finally: + pause_if_interactive() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/view_metadata.py b/view_metadata.py index 6d62eb4..560d489 100644 --- a/view_metadata.py +++ b/view_metadata.py @@ -10,12 +10,11 @@ If more than one file is provided or found, only the first file is shown. from __future__ import annotations -import subprocess import sys from pathlib import Path from tools.console import pause_if_interactive -from tools.exiftool import require_exiftool +from tools.exiftool import require_exiftool, run_exiftool_command from tools.filesystem import first_file_from_path @@ -44,18 +43,15 @@ def run(argv: list[str]) -> int: return 1 print(f"Showing metadata for: {file_path}\n", flush=True) - command = [ - exiftool, + args = [ "-a", "-u", "-ee", "-G0:1", "-s", - "-charset", - "filename=UTF8", str(file_path), ] - return subprocess.run(command).returncode + return run_exiftool_command(exiftool, args).returncode def main(argv: list[str]) -> int: