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)