360 lines
11 KiB
Python
360 lines
11 KiB
Python
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)
|