Add Avisynth video encoding workflow

This commit is contained in:
ajp_anton
2026-07-21 00:41:00 +00:00
parent f50f465465
commit 49296d6f45
37 changed files with 9487 additions and 76 deletions
+3
View File
@@ -1,4 +1,7 @@
old/ old/
tests/ tests/
PLAN.md PLAN.md
VIDEO_PLAN.md
__pycache__/ __pycache__/
video_workspace/
tools/avisynth_runner/mbt_avs_runner
+136 -3
View File
@@ -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. 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 ## Requirements
- Python 3.10 or newer - Python 3.10 or newer
- ExifTool available as `exiftool` on `PATH` - 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: 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 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. 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. `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 ## 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 ## AI Note
+3 -44
View File
@@ -8,43 +8,12 @@ onto the script and choose which is the source.
from __future__ import annotations from __future__ import annotations
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from tools.console import pause_if_interactive, prompt_input from tools.console import pause_if_interactive, prompt_input
from tools.exiftool import require_exiftool from tools.exiftool import require_exiftool
from tools.metadata_copy import copy_meaningful_metadata
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",
]
def prompt_path(prompt: str) -> Path: 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: def copy_metadata(exiftool: str, source: Path, destination: Path) -> int:
print(f"Source: {source}") print(f"Source: {source}")
print(f"Destination: {destination}", flush=True) print(f"Destination: {destination}", flush=True)
command = [ copy_meaningful_metadata(exiftool, source, destination)
exiftool, return 0
"-overwrite_original",
"-TagsFromFile",
str(source),
"-all:all",
*EXCLUDED_COPY_TAGS,
"-charset",
"filename=UTF8",
str(destination),
]
return subprocess.run(command).returncode
def run(argv: list[str]) -> int: def run(argv: list[str]) -> int:
+341
View File
@@ -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)
}
+231
View File
@@ -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
+110
View File
@@ -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"}
+21
View File
@@ -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"
+38
View File
@@ -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
+853
View File
@@ -0,0 +1,853 @@
#ifdef _WIN32
#define AVSC_NO_DECLSPEC
#include <fcntl.h>
#include <io.h>
#include <windows.h>
#endif
#include <avisynth_c.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#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;
}
Binary file not shown.
Binary file not shown.
+157
View File
@@ -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)"
+501
View File
@@ -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)
+327
View File
@@ -1,6 +1,14 @@
from __future__ import annotations from __future__ import annotations
import os
import sys 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: 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 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: def pause_if_interactive() -> None:
if sys.stdin.isatty(): if sys.stdin.isatty():
try: try:
input("\nPress Enter to close...") input("\nPress Enter to close...")
except EOFError: except EOFError:
pass 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))
+50
View File
@@ -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
+50 -20
View File
@@ -1,36 +1,69 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import subprocess import subprocess
import tempfile
from pathlib import Path from pathlib import Path
from shutil import which from typing import Any
from tools.executables import require_executable
def require_exiftool() -> str: def require_exiftool() -> str:
executable = which("exiftool") return require_executable("exiftool")
if executable:
return executable
raise RuntimeError( def _use_windows_argument_file() -> bool:
"exiftool was not found on PATH. Install ExifTool and make sure the " return os.name == "nt"
"'exiftool' command works before running this script."
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]) -> list[dict]: def run_exiftool_json(
executable: str,
paths: list[Path],
*,
quicktime_utc: bool = True,
) -> list[dict]:
results: list[dict] = [] results: list[dict] = []
for start in range(0, len(paths), 80): for start in range(0, len(paths), 80):
chunk = paths[start : start + 80] chunk = paths[start : start + 80]
command = [ args = [
executable,
"-j", "-j",
"-G", "-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 "[]")) results.extend(json.loads(completed.stdout or "[]"))
return results return results
@@ -39,11 +72,8 @@ def run_exiftool_write(executable: str, path: Path, args: list[str]) -> None:
if not args: if not args:
return return
command = [ command = [
executable,
"-overwrite_original", "-overwrite_original",
"-charset",
"filename=UTF8",
*args, *args,
str(path), str(path),
] ]
subprocess.run(command, check=True) run_exiftool_command(executable, command, check=True)
+13 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path 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") 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: def normalized_extension(path: Path) -> str:
return path.suffix.lower() return path.suffix.lower()
+139
View File
@@ -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}")
+36
View File
@@ -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}"
+999
View File
@@ -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
+222
View File
@@ -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}")
+641
View File
@@ -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)
+131
View File
@@ -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
+119
View File
@@ -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})"
+111
View File
@@ -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
+412
View File
@@ -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.")
+314
View File
@@ -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)
+523
View File
@@ -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)
+374
View File
@@ -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, ""
+169
View File
@@ -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"
+359
View File
@@ -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)
+246
View File
@@ -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:])
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import subprocess
import unittest
from pathlib import Path
from unittest.mock import patch
from tools.exiftool import run_exiftool_command
class ExiftoolInvocationTests(unittest.TestCase):
def test_windows_uses_utf8_argument_file_for_unicode_paths(self) -> None:
captured: dict[str, object] = {}
def fake_run(command, **kwargs):
captured["command"] = command
argument_file = Path(kwargs["cwd"]) / command[-1]
captured["contents"] = argument_file.read_text(encoding="utf-8")
return subprocess.CompletedProcess(command, 0)
with (
patch("tools.exiftool._use_windows_argument_file", return_value=True),
patch("tools.exiftool.subprocess.run", side_effect=fake_run),
):
run_exiftool_command(
"exiftool.exe",
["-j", "C:\\Photos\\M\u00e4rchen.jpg"],
check=True,
)
self.assertEqual(
captured["command"],
["exiftool.exe", "-charset", "filename=UTF8", "-@", "arguments.txt"],
)
self.assertEqual(captured["contents"], "-j\nC:\\Photos\\M\u00e4rchen.jpg\n")
if __name__ == "__main__":
unittest.main()
+427
View File
@@ -0,0 +1,427 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
WORKSPACE = REPO / "video_workspace"
FIXTURES = REPO / "tests"
STATIC_FFMPEG_DIR = Path("/tmp/media_batch_video_tools/ffmpeg-7.0.2-amd64-static")
AVISYNTH_LIB_DIR = Path("/tmp/media_batch_avisynth/install/lib")
FFMS2_LIB_DIR = Path("/tmp/media_batch_ffms2_pkg/root/usr/lib/x86_64-linux-gnu")
FFMS2_PLUGIN = Path("/tmp/media_batch_ffms2_avs_build_rc2/ffms2_avisynth_hidden.so")
AVS_RUNNER = REPO / "tools" / "avisynth_runner" / "mbt_avs_runner"
X264 = Path("/tmp/mbt-x264-build/x264")
MKVMERGE = Path("/tmp/mbt-video-mux-tools/bin/mkvmerge")
def scenario_environment() -> dict[str, str]:
env = os.environ.copy()
env["PATH"] = (
f"{STATIC_FFMPEG_DIR}:{X264.parent}:"
f"{MKVMERGE.parent}:{env.get('PATH', '')}"
)
env["LD_LIBRARY_PATH"] = (
f"{AVISYNTH_LIB_DIR}:{FFMS2_LIB_DIR}:"
f"{env.get('LD_LIBRARY_PATH', '')}"
)
env["MBT_FFMS2_PLUGIN"] = str(FFMS2_PLUGIN)
env["MBT_AVS_RUNNER"] = str(AVS_RUNNER)
env["MBT_VIDEO_FINAL_ACTION"] = "leave"
env["MBT_VIDEO_TIMESTAMP_NAMES"] = "0"
env["MBT_VIDEO_CODEC"] = "x264"
env["MBT_VIDEO_CRF"] = "35"
env["MBT_VIDEO_PRESET"] = "ultrafast"
return env
def require_scenario_prerequisites() -> dict[str, str]:
missing: list[str] = []
if not AVS_RUNNER.is_file():
missing.append(str(AVS_RUNNER))
if not FFMS2_PLUGIN.is_file():
missing.append(str(FFMS2_PLUGIN))
if not X264.is_file():
missing.append(str(X264))
if not MKVMERGE.is_file():
missing.append(str(MKVMERGE))
if not STATIC_FFMPEG_DIR.is_dir():
missing.append(str(STATIC_FFMPEG_DIR))
if not AVISYNTH_LIB_DIR.is_dir():
missing.append(str(AVISYNTH_LIB_DIR))
if not FFMS2_LIB_DIR.is_dir():
missing.append(str(FFMS2_LIB_DIR))
for tool in ("ffmpeg", "ffprobe", "exiftool", "x264", "mkvmerge"):
if shutil.which(tool, path=scenario_environment()["PATH"]) is None:
missing.append(tool)
if missing:
raise unittest.SkipTest(
"video end-to-end prerequisites are unavailable: "
+ ", ".join(missing)
)
return scenario_environment()
def ffprobe_json(path: Path, env: dict[str, str]) -> dict:
completed = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-show_streams",
"-show_format",
"-of",
"json",
str(path),
],
check=True,
capture_output=True,
text=True,
env=env,
)
return json.loads(completed.stdout)
def ffprobe_frame_timestamps(path: Path, env: dict[str, str]) -> list[float]:
completed = subprocess.run(
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"frame=best_effort_timestamp_time",
"-of",
"json",
str(path),
],
check=True,
capture_output=True,
text=True,
env=env,
)
data = json.loads(completed.stdout)
timestamps: list[float] = []
for frame in data.get("frames", []):
value = frame.get("best_effort_timestamp_time")
if value is not None:
timestamps.append(float(value))
return timestamps
def exiftool_json(path: Path, env: dict[str, str]) -> dict:
completed = subprocess.run(
["exiftool", "-json", "-a", "-u", "-G0:1", str(path)],
check=True,
capture_output=True,
text=True,
env=env,
)
return json.loads(completed.stdout)[0]
class VideoEncodeEndToEndTests(unittest.TestCase):
maxDiff = None
def setUp(self) -> None:
self.env = require_scenario_prerequisites()
if WORKSPACE.exists():
shutil.rmtree(WORKSPACE)
def tearDown(self) -> None:
if WORKSPACE.exists():
shutil.rmtree(WORKSPACE)
def copy_fixture(self, fixture_name: str, temp_root: Path) -> Path:
source = FIXTURES / fixture_name
if not source.is_file():
raise unittest.SkipTest(f"fixture missing: {source}")
target = temp_root / fixture_name
shutil.copy2(source, target)
return target
def create_workspace(self, source: Path) -> Path:
self.run_video_encode(source, encode=False)
script = WORKSPACE / f"{source.name}.avs"
self.assertTrue(script.is_file(), script)
return script
def run_video_encode(
self,
source: Path,
*,
encode: bool,
extra_env: dict[str, str] | None = None,
) -> str:
env = self.env.copy()
env["MBT_ENCODE_VIDEO"] = "1" if encode else "0"
if extra_env:
env.update(extra_env)
completed = subprocess.run(
["python3", "video_encode.py", str(source)],
cwd=REPO,
check=True,
capture_output=True,
text=True,
env=env,
)
return completed.stdout
def replace_user_script_body(self, script: Path, body: str) -> None:
marker = "# User edits below this line. The imported source clip will already be in \"last\"."
text = script.read_text(encoding="utf-8")
self.assertIn(marker, text)
prefix = text.split(marker, 1)[0] + marker + "\n\n"
script.write_text(prefix + body.strip() + "\n", encoding="utf-8")
def assert_video_stream_frames(self, output: Path, expected: int) -> None:
data = ffprobe_json(output, self.env)
video_stream = next(
stream for stream in data["streams"] if stream["codec_type"] == "video"
)
frame_count = int(video_stream.get("nb_frames") or len(ffprobe_frame_timestamps(output, self.env)))
self.assertEqual(frame_count, expected)
def assert_audio_codec(self, output: Path, expected: str) -> None:
data = ffprobe_json(output, self.env)
audio_stream = next(
stream for stream in data["streams"] if stream["codec_type"] == "audio"
)
self.assertEqual(audio_stream["codec_name"], expected)
def test_real_frame_validation_mode_renders_the_source(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("20260602_222842.mp4", Path(temp_dir))
self.create_workspace(source)
validation_script = WORKSPACE / ".source" / f"{source.name}.validate.avs"
validation_csv = WORKSPACE / ".source" / f"{source.name}.real.frames.csv"
completed = subprocess.run(
[str(AVS_RUNNER), "--validate-real", str(validation_script), str(validation_csv)],
check=True,
capture_output=True,
text=True,
env=self.env,
)
self.assertIn("rendered=170", completed.stdout)
self.assertEqual(len(validation_csv.read_text(encoding="utf-8").splitlines()), 171)
def test_cfr_mp4_no_edit_copies_metadata(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
self.create_workspace(source)
self.run_video_encode(
source,
encode=True,
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
)
output = WORKSPACE / "C0011.MP4.mp4"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 60)
self.assert_audio_codec(output, "aac")
self.assertFalse(output.with_name(f"{output.name}.manifest.json").exists())
self.assertFalse((WORKSPACE / ".source" / "avisynth_helpers.avs").exists())
self.assertFalse(list((WORKSPACE / ".source").rglob("*.frames.csv")))
self.assertFalse(list((WORKSPACE / ".source").rglob("*.runner.log")))
def test_vfr_mp4_trim_preserves_location_and_adjusts_end_timestamp(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("20260602_222842.mp4", Path(temp_dir))
script = self.create_workspace(source)
self.replace_user_script_body(script, "Trim(last, 0, 84)\nlast")
self.run_video_encode(
source,
encode=True,
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
)
output = WORKSPACE / "20260602_222842.mp4.mp4"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 85)
metadata = exiftool_json(output, self.env)
self.assertIn("Composite:GPSLatitude", metadata)
self.assertIn("Composite:GPSLongitude", metadata)
self.assertEqual(metadata.get("QuickTime:CreateDate"), "2026:06:02 13:28:46")
def test_vfr_mp4_x265_uses_post_encode_timecodes(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("20260602_222842.mp4", Path(temp_dir))
script = self.create_workspace(source)
self.replace_user_script_body(script, "Trim(last, 0, 84)\nlast")
self.run_video_encode(
source,
encode=True,
extra_env={
"MBT_VIDEO_CONTAINER": "mp4",
"MBT_VIDEO_CODEC": "x265",
"MBT_VIDEO_AUDIO": "aac",
},
)
output = WORKSPACE / "20260602_222842.mp4.mp4"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 85)
def test_select_even_uses_segmented_aac_audio(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
script = self.create_workspace(source)
self.replace_user_script_body(script, "SelectEven(last)\nlast")
self.run_video_encode(
source,
encode=True,
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
)
output = WORKSPACE / "C0011.MP4.mp4"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 30)
self.assert_audio_codec(output, "aac")
def test_mixed_normal_delete_and_drop_frame_preserves_drop_durations(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
script = self.create_workspace(source)
self.replace_user_script_body(
script,
"""
SelectEvery(last, 4, 0, 2, 3)
MBT_DropEvery(last, 3, 2)
last
""",
)
self.run_video_encode(
source,
encode=True,
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
)
output = WORKSPACE / "C0011.MP4.mp4"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 30)
def test_drop_frame_range_helper(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
script = self.create_workspace(source)
self.replace_user_script_body(script, "MBT_Drop(last, 1, 2)\nlast")
self.run_video_encode(
source,
encode=True,
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "aac"},
)
output = WORKSPACE / "C0011.MP4.mp4"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 58)
def test_rotate_crop_helper_crops_a_full_frame_turn(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-rotate-crop-") as temp_dir:
temp = Path(temp_dir)
helper = (REPO / "tools" / "avisynth_helpers.avs").as_posix()
cases = [
("source-dar", "RotateCrop(last, 10.0)", "width=1484,height=832"),
("square", "RotateCrop(last, 10.0, 1.0)", "width=932,height=932"),
]
for name, call, expected in cases:
script = temp / f"{name}.avs"
csv = temp / f"{name}.csv"
script.write_text(
"\n".join(
[
'function Turn(clip c, float "angle", int "lx", int "rx", int "ty", int "by")',
"{",
' Assert(!Defined(lx) && !Defined(rx) && !Defined(ty) && !Defined(by), "RotateCrop must not limit Turn\'s display rectangle")',
" return c",
"}",
f'Import("{helper}")',
'BlankClip(width=1920, height=1080, pixel_type="YV12")',
call,
]
)
+ "\n",
encoding="utf-8",
)
completed = subprocess.run(
[str(AVS_RUNNER), "--validate", str(script), str(csv)],
capture_output=True,
text=True,
env=self.env,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertIn(expected, completed.stdout)
def test_simple_trim_can_copy_audio(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
script = self.create_workspace(source)
self.replace_user_script_body(script, "Trim(last, 10, 49)\nlast")
self.run_video_encode(
source,
encode=True,
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "copy"},
)
output = WORKSPACE / "C0011.MP4.mp4"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 40)
self.assert_audio_codec(output, "pcm_s16be")
def test_mkv_opus_output(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
self.create_workspace(source)
self.run_video_encode(
source,
encode=True,
extra_env={"MBT_VIDEO_CONTAINER": "mkv", "MBT_VIDEO_AUDIO": "opus"},
)
output = WORKSPACE / "C0011.MP4.mkv"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 60)
self.assert_audio_codec(output, "opus")
def test_existing_valid_editable_script_is_kept(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
script = self.create_workspace(source)
original_text = script.read_text(encoding="utf-8")
output = self.run_video_encode(source, encode=False)
self.assertIn("existing editable scripts", output)
self.assertEqual(script.read_text(encoding="utf-8"), original_text)
def test_existing_invalid_editable_script_is_rejected_noninteractive(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
script = self.create_workspace(source)
script.write_text(
'BlankClip(length=1, width=16, height=16, pixel_type="YV12")\n',
encoding="utf-8",
)
env = self.env.copy()
env["MBT_ENCODE_VIDEO"] = "0"
completed = subprocess.run(
["python3", "video_encode.py", str(source)],
cwd=REPO,
capture_output=True,
text=True,
env=env,
)
self.assertNotEqual(completed.returncode, 0)
self.assertIn("invalid kept script", completed.stdout)
self.assertIn("not overwritten", completed.stdout)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+354
View File
@@ -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))
+3 -7
View File
@@ -10,12 +10,11 @@ If more than one file is provided or found, only the first file is shown.
from __future__ import annotations from __future__ import annotations
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from tools.console import pause_if_interactive 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 from tools.filesystem import first_file_from_path
@@ -44,18 +43,15 @@ def run(argv: list[str]) -> int:
return 1 return 1
print(f"Showing metadata for: {file_path}\n", flush=True) print(f"Showing metadata for: {file_path}\n", flush=True)
command = [ args = [
exiftool,
"-a", "-a",
"-u", "-u",
"-ee", "-ee",
"-G0:1", "-G0:1",
"-s", "-s",
"-charset",
"filename=UTF8",
str(file_path), str(file_path),
] ]
return subprocess.run(command).returncode return run_exiftool_command(exiftool, args).returncode
def main(argv: list[str]) -> int: def main(argv: list[str]) -> int: