Enhance Avisynth video workflow and metadata tools

This commit is contained in:
ajp_anton
2026-07-30 03:20:19 +00:00
parent d60ce51b14
commit 100cad1cb5
26 changed files with 2350 additions and 1161 deletions
+141 -106
View File
@@ -10,7 +10,9 @@ from pathlib import Path
from threading import Thread
from typing import Callable
from tools.avisynth_paths import avs_path
from tools.console import PipelineProgressView, ProgressView
from tools.video_color import ColorMetadata
from tools.video_formatting import chroma_family
@@ -46,6 +48,31 @@ class AudioEncodeOptions:
preserve_pitch: bool = True
@dataclass(frozen=True)
class VideoEncodeRequest:
y4m_command: list[str]
ffmpeg: Path
script: Path
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
color_metadata: ColorMetadata | None
static_hdr: tuple[str | None, int | None, int | None, bool] | None
dynamic_hdr10plus: Path | None
frame_count: int | None
progress_label: str
def default_audio_bitrate(mode: str) -> str:
return {"aac": "192k", "opus": "128k"}.get(mode, "")
@@ -136,14 +163,24 @@ def supported_pixel_formats(ffmpeg: Path, codec: str, family: str) -> list[tuple
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 add_x265_color_metadata(command: list[str], color_metadata: ColorMetadata) -> None:
value = color_metadata.x265_params()
if value:
add_codec_params(command, "-x265-params", value)
def x264_static_hdr_options(
static_hdr: tuple[str | None, int | None, int | None, bool] | None,
) -> list[str]:
if static_hdr is None:
return []
mastering_display, max_content, max_average, _ = static_hdr
options: list[str] = []
if mastering_display:
options.extend(["--mastering-display", mastering_display])
if max_content is not None:
options.extend(["--cll", f"{max_content},{max_average or 0}"])
return options
def render_audio_wav(
@@ -197,6 +234,8 @@ def remux_video(
command.extend(["-c:a", "copy"])
else:
add_audio_encoder_options(command, audio_options)
if output.suffix.lower() == ".mp4":
command.extend(["-movflags", "+write_colr"])
command.append(str(output))
process = subprocess.Popen(
command,
@@ -222,60 +261,20 @@ def remux_video(
)
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,
)
def encode_video_only_y4m(request: VideoEncodeRequest) -> VideoEncodeResult:
request.output.parent.mkdir(parents=True, exist_ok=True)
command = ffmpeg_y4m_command(request)
run = run_y4m_encoder(
y4m_command=y4m_command,
script=script,
y4m_command=request.y4m_command,
script=request.script,
encoder_command=command,
frame_count=frame_count,
progress_label=progress_label or output.name,
frame_count=request.frame_count,
progress_label=request.progress_label,
read_progress=read_ffmpeg_progress,
)
if run.encoder_returncode != 0:
raise RuntimeError(
f"FFmpeg failed for {script} with exit {run.encoder_returncode}:\n"
f"FFmpeg failed for {request.script} with exit {run.encoder_returncode}:\n"
+ run.encoder_log
+ (
"\nAvisynth runner also exited with "
@@ -287,71 +286,61 @@ def encode_video_only_y4m(
)
if run.renderer_returncode != 0:
raise RuntimeError(
f"Avisynth runner failed for {script} with exit {run.renderer_returncode}:\n"
f"Avisynth runner failed for {request.script} with exit {run.renderer_returncode}:\n"
+ run.renderer_log
)
return VideoEncodeResult(
script=script,
output=output,
script=request.script,
output=request.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]:
def ffmpeg_y4m_command(request: VideoEncodeRequest) -> list[str]:
command = [
str(ffmpeg), "-y", "-hide_banner", "-loglevel", "info", "-nostats",
str(request.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")
if request.audio_options.mode != "none" and request.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,
audio_options=request.audio_options,
audio_start=request.audio_start,
audio_duration=request.audio_duration,
allow_audio_copy=request.allow_audio_copy,
)
command.extend(["-i", str(audio_source)])
command.extend(["-i", str(request.audio_source)])
command.extend([
"-map", "0:v:0", "-c:v", options.codec, "-preset", options.preset,
"-crf", str(options.crf), "-pix_fmt", pixel_format,
"-map", "0:v:0", "-c:v", request.options.codec, "-preset", request.options.preset,
"-crf", str(request.options.crf), "-pix_fmt", request.pixel_format,
])
add_video_profile_level(command, options)
add_video_threads(command, options)
if colorspace is not None:
command.extend(["-colorspace", colorspace])
add_video_profile_level(command, request.options)
add_video_threads(command, request.options)
if request.options.codec == "libx265" and request.color_metadata:
add_x265_color_metadata(command, request.color_metadata)
add_static_hdr_metadata(command, request.options, request.static_hdr)
add_dynamic_hdr10plus_metadata(command, request.options, request.dynamic_hdr10plus)
if request.color_metadata is not None:
command.extend(request.color_metadata.ffmpeg_args())
elif request.colorspace is not None:
command.extend(["-colorspace", request.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,
audio_options=request.audio_options,
audio_start=request.audio_start,
audio_duration=request.audio_duration,
audio_segments=request.audio_segments,
audio_tempo=request.audio_tempo,
audio_sample_rate=request.audio_sample_rate,
source_has_audio=request.source_has_audio,
allow_audio_copy=request.allow_audio_copy,
)
command.append(str(output))
if request.output.suffix.lower() == ".mp4" and request.color_metadata and request.color_metadata.ffmpeg_args():
command.extend(["-movflags", "+write_colr"])
command.append(str(request.output))
return command
@@ -404,7 +393,7 @@ 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'
f'Import("{avs_path(script.name)}")\n'
"last\n",
encoding="utf-8",
)
@@ -414,10 +403,6 @@ def video_only_script(script: Path) -> Iterator[Path]:
wrapper.unlink(missing_ok=True)
def _avs_path(path: str) -> str:
return path.replace("\\", "/").replace('"', '\\"')
def add_audio_input_options(
command: list[str],
*,
@@ -490,11 +475,61 @@ 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}"])
add_codec_params(command, "-x265-params", f"pools={options.threads}")
else:
command.extend(["-threads", str(options.threads)])
def add_static_hdr_metadata(
command: list[str],
options: VideoCodecOptions,
static_hdr: tuple[str | None, int | None, int | None, bool] | None,
) -> None:
if static_hdr is None:
return
mastering_display, max_content, max_average, is_pq = static_hdr
params: list[str] = ["hdr10=1"] if options.codec == "libx265" and is_pq else []
if mastering_display:
params.append(f"master-display={mastering_display}")
if max_content is not None:
params.append(f"max-cll={max_content},{max_average or 0}")
if not params:
return
option = "-x265-params" if options.codec == "libx265" else "-x264-params"
add_codec_params(command, option, ":".join(params))
def add_dynamic_hdr10plus_metadata(
command: list[str],
options: VideoCodecOptions,
metadata_json: Path | None,
) -> None:
if metadata_json is None:
return
if options.codec != "libx265":
raise RuntimeError("HDR10+ re-encoding requires x265")
# FFmpeg passes x265 options as a colon-separated string. Escaping keeps
# the Windows drive separator inside the JSON path value.
json_path = x265_parameter_path(metadata_json.resolve())
add_codec_params(
command,
"-x265-params",
f"dhdr10-info={json_path}:dhdr10-opt=1",
)
def x265_parameter_path(path: Path) -> str:
return str(path).replace("\\", "/").replace(":", r"\:")
def add_codec_params(command: list[str], option: str, value: str) -> None:
if option in command:
index = command.index(option) + 1
command[index] = f"{command[index]}:{value}"
else:
command.extend([option, value])
def print_encoder_statistics(encoder_log: str) -> None:
statistics = encoder_statistics_lines(encoder_log)
if not statistics: