Add Avisynth video encoding workflow
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user