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
+70 -122
View File
@@ -4,12 +4,14 @@ import re
import subprocess
import sys
import tempfile
from dataclasses import replace
from pathlib import Path
from tools.console import PipelineProgressView, ProgressView
from tools.video_color import ColorMetadata
from tools.video_encode_output import (
AudioEncodeOptions,
VideoCodecOptions,
VideoEncodeRequest,
VideoEncodeResult,
add_audio_input_options,
add_audio_options,
@@ -17,78 +19,46 @@ from tools.video_encode_output import (
pixel_format_bit_depth,
read_ffmpeg_progress,
run_y4m_encoder,
x264_static_hdr_options,
)
def encode_video_with_timestamps(
*,
y4m_command: list[str],
ffmpeg: Path,
request: VideoEncodeRequest,
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)
request.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"
"encoded.h264" if request.options.codec == "libx264" else "encoded.mkv"
)
if options.codec == "libx264":
if request.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,
request=request,
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":
elif request.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,
replace(request, output=intermediate, audio_options=AudioEncodeOptions(mode="none"))
)
renderer_returncode = result.renderer_returncode
encoder_log = result.encoder_log
else:
raise RuntimeError(f"Unsupported timestamp-aware codec: {options.codec}")
raise RuntimeError(f"Unsupported timestamp-aware codec: {request.options.codec}")
timed_video = temp_root / "timed.mkv"
apply_video_timecodes(
@@ -96,27 +66,16 @@ def encode_video_with_timestamps(
source=intermediate,
timecodes=timecodes,
output=timed_video,
color_metadata=request.color_metadata,
)
ffmpeg_returncode = mux_timed_video_with_audio(
ffmpeg=ffmpeg,
request=request,
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,
script=request.script,
output=request.output,
renderer_returncode=renderer_returncode,
ffmpeg_returncode=ffmpeg_returncode,
encoder_log=encoder_log,
@@ -136,30 +95,22 @@ def write_timecode_v2(path: Path, frame_durations: list[float]) -> None:
def encode_x264_y4m(
*,
y4m_command: list[str],
request: VideoEncodeRequest,
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,
y4m_command=request.y4m_command,
script=request.script,
encoder_command=x264_command(
request=request,
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,
frame_count=request.frame_count,
progress_label=request.progress_label,
read_progress=read_x264_progress,
)
if run.encoder_returncode != 0:
@@ -167,12 +118,12 @@ def encode_x264_y4m(
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"
f"x264 failed for {request.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"
f"Avisynth runner failed for {request.script} with exit {run.renderer_returncode}:\n"
+ run.renderer_log
)
return run.renderer_returncode, run.encoder_log
@@ -180,12 +131,10 @@ def encode_x264_y4m(
def x264_command(
*,
request: VideoEncodeRequest,
x264: Path,
timecodes: Path,
output: Path,
options: VideoCodecOptions,
pixel_format: str,
colorspace: str | None,
) -> list[str]:
command = [
str(x264),
@@ -194,24 +143,27 @@ def x264_command(
"--tcfile-in",
str(timecodes),
"--crf",
str(options.crf),
str(request.options.crf),
"--preset",
options.preset,
request.options.preset,
"--output-depth",
str(pixel_format_bit_depth(pixel_format)),
str(pixel_format_bit_depth(request.pixel_format)),
"--output-csp",
_x264_output_csp(pixel_format),
_x264_output_csp(request.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])
if request.options.profile:
command.extend(["--profile", request.options.profile])
if request.options.level:
command.extend(["--level", request.options.level])
if request.options.threads is not None:
command.extend(["--threads", str(request.options.threads)])
if request.color_metadata is not None:
command.extend(request.color_metadata.x264_args())
elif request.colorspace:
command.extend(["--colormatrix", request.colorspace])
command.extend(x264_static_hdr_options(request.static_hdr))
command.extend(["--output", str(output), "-"])
return command
@@ -230,6 +182,7 @@ def apply_video_timecodes(
source: Path,
timecodes: Path,
output: Path,
color_metadata: ColorMetadata | None = None,
) -> None:
completed = subprocess.run(
[
@@ -241,6 +194,7 @@ def apply_video_timecodes(
str(output),
"--timestamps",
f"0:{timecodes}",
*(color_metadata.mkvmerge_args() if color_metadata else []),
str(source),
],
capture_output=True,
@@ -256,23 +210,11 @@ def apply_video_timecodes(
def mux_timed_video_with_audio(
*,
ffmpeg: Path,
request: VideoEncodeRequest,
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),
str(request.ffmpeg),
"-y",
"-hide_banner",
"-loglevel",
@@ -283,31 +225,37 @@ def mux_timed_video_with_audio(
"-i",
str(timed_video),
]
if audio_options.mode != "none" and source_has_audio:
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", "copy"])
if output.suffix.lower() == ".mp4":
command.extend(["-video_track_timescale", "90000"])
if request.color_metadata:
command.extend(request.color_metadata.ffmpeg_args())
if request.options.codec == "libx265":
bitstream_filter = request.color_metadata.hevc_bitstream_filter() if request.color_metadata else None
if bitstream_filter:
command.extend(["-bsf:v", bitstream_filter])
if request.output.suffix.lower() == ".mp4":
command.extend(["-video_track_timescale", "90000", "-movflags", "+write_colr"])
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))
command.append(str(request.output))
process = subprocess.Popen(
command,
@@ -317,12 +265,12 @@ def mux_timed_video_with_audio(
)
ffmpeg_stderr = read_ffmpeg_progress(
process,
total_frames=frame_count,
label=f"Muxing {progress_label or output.name}",
total_frames=request.frame_count,
label=f"Muxing {request.progress_label}",
)
if process.returncode != 0:
raise RuntimeError(
f"FFmpeg failed while muxing timestamped output {output} with exit "
f"FFmpeg failed while muxing timestamped output {request.output} with exit "
f"{process.returncode}:\n{ffmpeg_stderr}"
)
return process.returncode