Add Avisynth video encoding workflow
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from tools.console import prompt_input, prompt_yes_no
|
||||
from tools.exiftool import run_exiftool_command
|
||||
from tools.filenames import format_rounded_filename_stem, round_datetime_to_second
|
||||
from tools.metadata_copy import copy_meaningful_metadata
|
||||
from tools.video_inputs import VideoInput
|
||||
from tools.video_options import OutputNamingOptions
|
||||
from tools.video_probe import VideoProbe, probe_video
|
||||
from tools.video_timeline import OutputTimeline
|
||||
from tools.video_formatting import join_parts, video_parts
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncodedItem:
|
||||
input_item: VideoInput
|
||||
output: Path
|
||||
|
||||
|
||||
def planned_output_path(
|
||||
*,
|
||||
script: Path,
|
||||
timeline: OutputTimeline,
|
||||
probe: VideoProbe,
|
||||
naming: OutputNamingOptions,
|
||||
extension: str,
|
||||
planned_outputs: set[Path],
|
||||
overwrite_existing: bool = True,
|
||||
) -> Path:
|
||||
if naming.use_timestamps:
|
||||
output_begin = output_begin_timestamp(probe, timeline, naming.timezone_value)
|
||||
stem = format_rounded_filename_stem(output_begin)
|
||||
candidate = script.parent / f"{stem}{extension}"
|
||||
else:
|
||||
candidate = script.with_suffix(extension)
|
||||
|
||||
for index in range(10000):
|
||||
target = candidate if index == 0 else candidate.with_name(
|
||||
f"{candidate.stem}-{index}{candidate.suffix}"
|
||||
)
|
||||
if target.resolve() in planned_outputs:
|
||||
continue
|
||||
if not overwrite_existing and target.exists():
|
||||
continue
|
||||
planned_outputs.add(target.resolve())
|
||||
return target
|
||||
|
||||
raise RuntimeError(f"Could not find an available output name for {candidate}")
|
||||
|
||||
|
||||
def output_begin_timestamp(
|
||||
probe: VideoProbe,
|
||||
timeline: OutputTimeline,
|
||||
output_timezone: timezone,
|
||||
) -> datetime:
|
||||
if probe.metadata_end is None:
|
||||
raise RuntimeError(f"No metadata end timestamp found for {probe.path}")
|
||||
if probe.duration_seconds is None:
|
||||
raise RuntimeError(f"No source duration found for {probe.path}")
|
||||
|
||||
source_end = probe.metadata_end
|
||||
if source_end.tzinfo is None:
|
||||
source_end = source_end.replace(tzinfo=timezone.utc)
|
||||
source_begin = source_end - timedelta(seconds=probe.duration_seconds)
|
||||
first_frame_start = timeline.frames[0].start
|
||||
return (source_begin + timedelta(seconds=first_frame_start)).astimezone(
|
||||
output_timezone
|
||||
)
|
||||
|
||||
|
||||
def output_end_timestamp(
|
||||
probe: VideoProbe,
|
||||
timeline: OutputTimeline,
|
||||
output_duration: float,
|
||||
) -> datetime:
|
||||
begin = output_begin_timestamp(probe, timeline, timezone.utc)
|
||||
return begin + timedelta(seconds=output_duration)
|
||||
|
||||
|
||||
def write_video_end_timestamp(
|
||||
exiftool: str,
|
||||
output: Path,
|
||||
probe: VideoProbe,
|
||||
timeline: OutputTimeline,
|
||||
output_duration: float,
|
||||
) -> None:
|
||||
if output.suffix.lower() not in {".mp4", ".mov"}:
|
||||
print(f" skipped container timestamp write for {output.suffix}")
|
||||
return
|
||||
end_time = round_datetime_to_second(
|
||||
output_end_timestamp(probe, timeline, output_duration).astimezone(timezone.utc)
|
||||
)
|
||||
timestamp = end_time.strftime("%Y:%m:%d %H:%M:%S")
|
||||
args = [
|
||||
"-overwrite_original",
|
||||
f"-QuickTime:CreateDate={timestamp}",
|
||||
f"-QuickTime:ModifyDate={timestamp}",
|
||||
f"-QuickTime:TrackCreateDate={timestamp}",
|
||||
f"-QuickTime:TrackModifyDate={timestamp}",
|
||||
f"-QuickTime:MediaCreateDate={timestamp}",
|
||||
f"-QuickTime:MediaModifyDate={timestamp}",
|
||||
str(output),
|
||||
]
|
||||
run_exiftool_command(
|
||||
exiftool,
|
||||
args,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
print(f" wrote video end timestamp: {timestamp} UTC")
|
||||
|
||||
|
||||
def copy_video_metadata(exiftool: str, source: Path, output: Path) -> None:
|
||||
copy_meaningful_metadata(exiftool, source, output)
|
||||
if output.suffix.lower() in {".mp4", ".mov"}:
|
||||
print(" copied source metadata")
|
||||
else:
|
||||
print(" copied source metadata where supported by container")
|
||||
|
||||
|
||||
def validate_encoded_video(
|
||||
ffprobe: str,
|
||||
output: Path,
|
||||
expected_timeline: OutputTimeline,
|
||||
*,
|
||||
expected_duration: float,
|
||||
expected_timestamps: list[float] | None = None,
|
||||
expected_audio: bool,
|
||||
) -> VideoProbe:
|
||||
probe = probe_video(ffprobe, output, {})
|
||||
expected_frames = len(expected_timeline.frames)
|
||||
if probe.timing.frame_count != expected_frames:
|
||||
raise RuntimeError(
|
||||
f"Encoded output frame count mismatch for {output}: "
|
||||
f"expected {expected_frames}, got {probe.timing.frame_count}"
|
||||
)
|
||||
if probe.duration_seconds is None:
|
||||
raise RuntimeError(f"Encoded output duration is unknown for {output}")
|
||||
duration_delta = abs(probe.duration_seconds - expected_duration)
|
||||
duration_tolerance = 0.05
|
||||
if expected_timestamps is not None and expected_timestamps:
|
||||
# MP4 packet start timestamps can validate exactly, but ffprobe often
|
||||
# reports video stream duration without the final sample duration.
|
||||
final_sample_duration = expected_duration - expected_timestamps[-1]
|
||||
duration_tolerance = max(duration_tolerance, final_sample_duration + 0.05)
|
||||
if duration_delta > duration_tolerance:
|
||||
raise RuntimeError(
|
||||
f"Encoded output duration mismatch for {output}: expected "
|
||||
f"{expected_duration:.3f}s, got "
|
||||
f"{probe.duration_seconds:.3f}s"
|
||||
)
|
||||
if expected_timestamps is not None:
|
||||
validate_encoded_timestamps(output, probe.frame_timestamps, expected_timestamps)
|
||||
elif probe.timing.timing_kind != "cfr":
|
||||
raise RuntimeError(
|
||||
f"Encoded output should be CFR for this milestone, got "
|
||||
f"{probe.timing.timing_kind.upper()} for {output}"
|
||||
)
|
||||
if expected_audio and probe.audio_stream_count <= 0:
|
||||
raise RuntimeError(f"Encoded output is missing expected audio: {output}")
|
||||
print(
|
||||
" verified output: "
|
||||
f"{probe.timing.frame_count} frame(s), {probe.duration_seconds:.3f}s, "
|
||||
f"{probe.timing.timing_kind.upper()}, "
|
||||
f"{probe.audio_stream_count} audio stream(s)"
|
||||
)
|
||||
print(
|
||||
" output video: "
|
||||
+ join_parts(
|
||||
video_parts(
|
||||
codec=probe.codec,
|
||||
width=probe.width,
|
||||
height=probe.height,
|
||||
pixel_format=probe.pixel_format,
|
||||
bit_depth=probe.bit_depth,
|
||||
matrix=probe.color_matrix,
|
||||
bit_rate=probe.video_bit_rate,
|
||||
)
|
||||
)
|
||||
)
|
||||
return probe
|
||||
|
||||
|
||||
def validate_encoded_timestamps(
|
||||
output: Path,
|
||||
actual: list[float],
|
||||
expected: list[float],
|
||||
) -> None:
|
||||
if len(actual) != len(expected):
|
||||
raise RuntimeError(
|
||||
f"Encoded output timestamp count mismatch for {output}: "
|
||||
f"expected {len(expected)}, got {len(actual)}"
|
||||
)
|
||||
for index, (actual_timestamp, expected_timestamp) in enumerate(zip(actual, expected)):
|
||||
delta = abs(actual_timestamp - expected_timestamp)
|
||||
if delta > 0.001:
|
||||
raise RuntimeError(
|
||||
f"Encoded output timestamp mismatch for {output} at frame {index}: "
|
||||
f"expected {expected_timestamp:.6f}s, got {actual_timestamp:.6f}s"
|
||||
)
|
||||
|
||||
|
||||
def finalize_encoded_outputs(root: Path, encoded_items: list[EncodedItem]) -> None:
|
||||
action = ask_final_action()
|
||||
if action == "leave":
|
||||
print("\nLeaving encoded output(s) in the workspace.")
|
||||
return
|
||||
|
||||
print("\nMoving encoded output(s) to source folder(s)...")
|
||||
backups: list[Path] = []
|
||||
moved: list[Path] = []
|
||||
for item in encoded_items:
|
||||
backup = move_encoded_output_to_source_dir(root, item, action)
|
||||
if backup is not None:
|
||||
backups.append(backup)
|
||||
moved.append(item.input_item.path.parent / item.output.name)
|
||||
|
||||
for path in moved:
|
||||
print(f" moved: {path}")
|
||||
for path in backups:
|
||||
print(f" original backup: {path}")
|
||||
|
||||
if ask_delete_workspace_after_move(has_backups=bool(backups)):
|
||||
shutil.rmtree(root)
|
||||
print(f"Deleted workspace: {root}")
|
||||
|
||||
|
||||
def ask_final_action() -> str:
|
||||
env_value = os.environ.get("MBT_VIDEO_FINAL_ACTION", "").strip().lower()
|
||||
actions = {
|
||||
"leave": "leave",
|
||||
"l": "leave",
|
||||
"replace": "replace",
|
||||
"move-replace": "replace",
|
||||
"delete-originals": "replace",
|
||||
"backup": "backup",
|
||||
"move-backup": "backup",
|
||||
"backup-originals": "backup",
|
||||
}
|
||||
if env_value:
|
||||
try:
|
||||
return actions[env_value]
|
||||
except KeyError as exc:
|
||||
raise RuntimeError(
|
||||
"MBT_VIDEO_FINAL_ACTION must be leave, replace, or backup"
|
||||
) from exc
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
return "leave"
|
||||
|
||||
while True:
|
||||
answer = prompt_input(
|
||||
"\nFinished files: Enter=leave in workspace, "
|
||||
"r=move to source dirs and delete originals, "
|
||||
"b=move to source dirs and back up originals: "
|
||||
).strip().lower()
|
||||
if not answer:
|
||||
return "leave"
|
||||
if answer in {"r", "replace"}:
|
||||
return "replace"
|
||||
if answer in {"b", "backup"}:
|
||||
return "backup"
|
||||
print("Please choose Enter, r, or b.")
|
||||
|
||||
|
||||
def move_encoded_output_to_source_dir(
|
||||
root: Path,
|
||||
item: EncodedItem,
|
||||
action: str,
|
||||
) -> Path | None:
|
||||
source = item.input_item.path
|
||||
output = item.output
|
||||
target = source.parent / output.name
|
||||
backup: Path | None = None
|
||||
|
||||
if target.exists() and target.resolve() != source.resolve():
|
||||
raise RuntimeError(f"Destination already exists: {target}")
|
||||
|
||||
if action == "backup":
|
||||
backup = root / "originals" / item.input_item.collapsed_relative
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
backup = unique_path(backup)
|
||||
shutil.move(str(source), str(backup))
|
||||
elif action == "replace":
|
||||
if source.exists():
|
||||
source.unlink()
|
||||
|
||||
if target.exists():
|
||||
raise RuntimeError(f"Destination already exists: {target}")
|
||||
|
||||
shutil.move(str(output), str(target))
|
||||
return backup
|
||||
|
||||
|
||||
def ask_delete_workspace_after_move(*, has_backups: bool) -> bool:
|
||||
env_value = os.environ.get("MBT_VIDEO_DELETE_WORKSPACE", "").strip().lower()
|
||||
if env_value:
|
||||
return env_value in {"1", "true", "yes", "y", "on"}
|
||||
if not sys.stdin.isatty():
|
||||
return False
|
||||
if has_backups:
|
||||
return prompt_yes_no(
|
||||
"Delete video_workspace too? This will delete original backups stored there.",
|
||||
default=False,
|
||||
)
|
||||
return prompt_yes_no("Delete video_workspace after moving outputs?", default=True)
|
||||
Reference in New Issue
Block a user