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

405 lines
13 KiB
Python

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.video_color import ColorMetadata
from tools.exiftool import run_exiftool_command
from tools.filesystem import unique_path
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_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
@dataclass(frozen=True)
class OutputNamingOptions:
use_timestamps: bool
timezone_value: timezone
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_begin = _stretched_source_begin_timestamp(probe, timeline)
first_frame_start = timeline.frames[0].start
return (source_begin + timedelta(
seconds=first_frame_start * timeline.absolute_timestretch
)).astimezone(
output_timezone
)
def output_end_timestamp(
probe: VideoProbe,
timeline: OutputTimeline,
) -> datetime:
source_begin = _stretched_source_begin_timestamp(probe, timeline)
return source_begin + timedelta(
seconds=_last_consumed_source_end(probe, timeline)
* timeline.absolute_timestretch
)
def _stretched_source_begin_timestamp(
probe: VideoProbe,
timeline: OutputTimeline,
) -> 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)
return source_end - timedelta(
seconds=probe.duration_seconds * timeline.absolute_timestretch
)
def _last_consumed_source_end(probe: VideoProbe, timeline: OutputTimeline) -> float:
source_frames = timeline.consumed_source_frames or [
frame.source_frame for frame in timeline.frames
]
if not source_frames:
raise RuntimeError(f"No source frames were consumed for {probe.path}")
source_frame = max(source_frames)
if source_frame >= len(probe.frame_timestamps):
raise RuntimeError(
f"source frame {source_frame} is outside probed frame table for {probe.path}"
)
start = probe.frame_timestamps[source_frame]
if source_frame + 1 < len(probe.frame_timestamps):
return probe.frame_timestamps[source_frame + 1]
if probe.duration_seconds is None:
raise RuntimeError(f"No source duration found for {probe.path}")
return max(start, probe.duration_seconds)
def write_video_end_timestamp(
exiftool: str,
output: Path,
probe: VideoProbe,
timeline: OutputTimeline,
) -> 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,
).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,
)
speed_note = (
""
if timeline.absolute_timestretch == 1
else f" ({timeline.absolute_timestretch:g}x real-time)"
)
print(f" wrote video end timestamp: {timestamp} UTC{speed_note}")
def copy_video_metadata(exiftool: str, source: Path, output: Path) -> None:
copy_meaningful_metadata(
exiftool,
source,
output,
extra_excluded_tags=[
"--QuickTime:ColorRepresentation",
"--QuickTime:ColorPrimaries",
"--QuickTime:TransferCharacteristics",
"--QuickTime:MatrixCoefficients",
],
)
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_output_color_metadata(
output: Path,
probe: VideoProbe,
timeline: OutputTimeline,
*,
expected_hdr10plus: bool = False,
) -> None:
expected = ColorMetadata(
timeline.matrix, timeline.primaries, timeline.transfer, timeline.color_range
).expected_probe_values()
actual = {
"matrix": probe.color_matrix,
"primaries": probe.color_primaries,
"transfer": probe.color_transfer,
"range": probe.color_range,
}
mismatches = [
f"{name} {actual[name] or 'missing'} (expected {value})"
for name, value in expected.items()
if value is not None and actual[name] != value
]
if mismatches:
raise RuntimeError(
f"Encoded output lost color metadata for {output}: "
+ "; ".join(mismatches)
)
if expected_hdr10plus and not probe.hdr10plus:
raise RuntimeError(f"Encoded output lost HDR10+ metadata for {output}")
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)