Add Avisynth video encoding workflow
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
Executable
+21
@@ -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"
|
||||||
Executable
+38
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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)"
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
+51
-21
@@ -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(
|
|
||||||
"exiftool was not found on PATH. Install ExifTool and make sure the "
|
|
||||||
"'exiftool' command works before running this script."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_exiftool_json(executable: str, paths: list[Path]) -> list[dict]:
|
def _use_windows_argument_file() -> bool:
|
||||||
|
return os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
|
def run_exiftool_command(
|
||||||
|
executable: str,
|
||||||
|
args: list[str],
|
||||||
|
**run_kwargs: Any,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
"""Run ExifTool with Unicode-safe filename arguments on Windows."""
|
||||||
|
command = [executable, "-charset", "filename=UTF8", *args]
|
||||||
|
if not _use_windows_argument_file():
|
||||||
|
return subprocess.run(command, **run_kwargs)
|
||||||
|
|
||||||
|
# ExifTool can't reliably receive arbitrary Unicode paths on the Windows
|
||||||
|
# command line. The outer path is ASCII and the UTF-8 argument file holds
|
||||||
|
# the actual paths and options.
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mbt-exiftool-") as temp_dir:
|
||||||
|
argument_file = Path(temp_dir) / "arguments.txt"
|
||||||
|
argument_file.write_text("\n".join(args) + "\n", encoding="utf-8")
|
||||||
|
return subprocess.run(
|
||||||
|
[executable, "-charset", "filename=UTF8", "-@", argument_file.name],
|
||||||
|
cwd=temp_dir,
|
||||||
|
**run_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_exiftool_json(
|
||||||
|
executable: str,
|
||||||
|
paths: list[Path],
|
||||||
|
*,
|
||||||
|
quicktime_utc: bool = True,
|
||||||
|
) -> list[dict]:
|
||||||
results: list[dict] = []
|
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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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})"
|
||||||
@@ -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
|
||||||
@@ -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.")
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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, ""
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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:])
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user