Enhance Avisynth video workflow and metadata tools
This commit is contained in:
+141
-106
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user