Files
media-batch-tools/tools/video_timestamp_encode.py
T

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)