diff --git a/Python/video-merger/main.py b/Python/video-merger/main.py new file mode 100644 index 0000000..ef71eb3 --- /dev/null +++ b/Python/video-merger/main.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Bundle video sketches into ~40-minute episodes with short/long alternation.""" + +from __future__ import annotations + +import argparse +import shutil +import statistics +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +VIDEO_EXTENSIONS = {".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v"} +TARGET_WIDTH = 1920 +TARGET_HEIGHT = 1080 + + +@dataclass +class Sketch: + path: Path + duration: float # seconds + label: str = "" + + @property + def name(self) -> str: + return self.label or self.path.name + + +def format_duration(seconds: float) -> str: + total = int(round(seconds)) + minutes, secs = divmod(total, 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + + +def require_binaries() -> None: + missing = [name for name in ("ffprobe", "ffmpeg") if shutil.which(name) is None] + if missing: + print( + f"Error: missing required binary/binaries: {', '.join(missing)}. " + "Install ffmpeg and ensure it is on PATH.", + file=sys.stderr, + ) + sys.exit(1) + + +def scan_videos(folder: Path) -> list[Path]: + files = sorted( + p + for p in folder.rglob("*") + if p.is_file() and p.suffix.lower() in VIDEO_EXTENSIONS + ) + if not files: + print(f"Error: no video files found in {folder}", file=sys.stderr) + sys.exit(1) + return files + + +def probe_duration(path: Path) -> float: + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "csv=p=0", + str(path), + ], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"ffprobe failed for {path.name}: {result.stderr.strip() or 'unknown error'}" + ) + value = result.stdout.strip() + if not value: + raise RuntimeError(f"ffprobe returned empty duration for {path.name}") + return float(value) + + +def load_sketches(folder: Path) -> list[Sketch]: + sketches: list[Sketch] = [] + for path in scan_videos(folder): + try: + duration = probe_duration(path) + except (RuntimeError, ValueError) as exc: + print(f"Warning: skipping {path}: {exc}", file=sys.stderr) + continue + try: + label = str(path.relative_to(folder)) + except ValueError: + label = path.name + sketches.append(Sketch(path=path, duration=duration, label=label)) + if not sketches: + print("Error: no sketches with readable duration", file=sys.stderr) + sys.exit(1) + return sketches + + +def split_short_long(sketches: list[Sketch]) -> tuple[list[Sketch], list[Sketch]]: + median = statistics.median(s.duration for s in sketches) + short = [s for s in sketches if s.duration <= median] + long = [s for s in sketches if s.duration > median] + # If everything landed in one bucket (e.g. identical lengths), keep packing workable. + if not short or not long: + by_duration = sorted(sketches, key=lambda s: s.duration) + mid = len(by_duration) // 2 + short, long = by_duration[:mid], by_duration[mid:] + if not short and long: + short.append(long.pop(0)) + elif not long and short: + long.append(short.pop()) + return short, long + + +def alternating_order(sketches: list[Sketch]) -> list[Sketch]: + """Alternate short/long within a set of sketches (median split).""" + if len(sketches) <= 1: + return list(sketches) + short, long = split_short_long(sketches) + short_q = sorted(short, key=lambda s: s.duration) + long_q = sorted(long, key=lambda s: s.duration, reverse=True) + + ordered: list[Sketch] = [] + prefer_short = True + while short_q or long_q: + primary, secondary = (short_q, long_q) if prefer_short else (long_q, short_q) + if primary: + ordered.append(primary.pop(0)) + elif secondary: + ordered.append(secondary.pop(0)) + prefer_short = not prefer_short + return ordered + + +def pack_into_n_episodes( + sketches: list[Sketch], episode_count: int +) -> tuple[list[list[Sketch]], float]: + """Pack into exactly episode_count balanced episodes. + + Uses longest-processing-time-first bin packing for even lengths, then + reorders each episode for short/long alternation. + """ + if episode_count < 1: + raise ValueError("episode_count must be at least 1") + if episode_count > len(sketches): + raise ValueError( + f"cannot make {episode_count} episodes from only {len(sketches)} sketches" + ) + + total = sum(s.duration for s in sketches) + target_seconds = total / episode_count + + # LPT multiprocessor scheduling: place longest sketches first into the + # currently shortest episode — minimizes length spread. + by_length = sorted(sketches, key=lambda s: (-s.duration, s.name)) + episodes: list[list[Sketch]] = [[] for _ in range(episode_count)] + durations = [0.0] * episode_count + for sketch in by_length: + index = min(range(episode_count), key=lambda i: (durations[i], i)) + episodes[index].append(sketch) + durations[index] += sketch.duration + + episodes = [alternating_order(ep) for ep in episodes] + return episodes, target_seconds + + +def pack_episodes( + sketches: list[Sketch], target_seconds: float +) -> list[list[Sketch]]: + """Pack into episodes of roughly target_seconds, balanced in length.""" + total = sum(s.duration for s in sketches) + episode_count = max(1, round(total / target_seconds)) + episode_count = min(episode_count, len(sketches)) + episodes, _ = pack_into_n_episodes(sketches, episode_count) + return episodes + + +def print_plan(episodes: list[list[Sketch]], target_seconds: float) -> None: + print(f"Target episode length: {format_duration(target_seconds)}") + print(f"Episodes: {len(episodes)}\n") + for index, episode in enumerate(episodes, start=1): + total = sum(s.duration for s in episode) + print(f"=== Aflevering {index:02d} ({format_duration(total)}) ===") + for sketch in episode: + print(f" {format_duration(sketch.duration):>8} {sketch.name}") + print() + + +def nvenc_available(codec: str) -> bool: + encoder = "hevc_nvenc" if codec == "h265" else "h264_nvenc" + result = subprocess.run( + ["ffmpeg", "-hide_banner", "-encoders"], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 and encoder in result.stdout + + +def encoder_name(use_gpu: bool, codec: str) -> str: + if codec == "h265": + return "hevc_nvenc" if use_gpu else "libx265" + return "h264_nvenc" if use_gpu else "libx264" + + +def video_encoder_args(use_gpu: bool, codec: str) -> list[str]: + if codec == "h265": + if use_gpu: + return [ + "-c:v", + "hevc_nvenc", + "-preset", + "p4", + "-rc", + "vbr", + "-cq", + "28", + "-b:v", + "0", + "-tag:v", + "hvc1", + ] + return [ + "-c:v", + "libx265", + "-preset", + "medium", + "-crf", + "28", + "-tag:v", + "hvc1", + ] + + if use_gpu: + return [ + "-c:v", + "h264_nvenc", + "-preset", + "p4", + "-rc", + "vbr", + "-cq", + "23", + "-b:v", + "0", + "-profile:v", + "high", + ] + return [ + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + "23", + ] + + +def merge_episode( + episode: list[Sketch], + output_path: Path, + *, + use_gpu: bool = False, + codec: str = "h264", +) -> None: + """Re-encode and concat sketches into one episode file.""" + n = len(episode) + cmd: list[str] = ["ffmpeg", "-y"] + for sketch in episode: + cmd.extend(["-i", str(sketch.path)]) + + # Scale/pad to a common canvas, then concat. Force consistent fps/sample rate. + filter_parts: list[str] = [] + for i in range(n): + filter_parts.append( + f"[{i}:v]scale={TARGET_WIDTH}:{TARGET_HEIGHT}:" + f"force_original_aspect_ratio=decrease," + f"pad={TARGET_WIDTH}:{TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2," + f"setsar=1,fps=25,format=yuv420p[v{i}];" + f"[{i}:a]aformat=sample_rates=48000:channel_layouts=stereo," + f"aresample=async=1[a{i}]" + ) + concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(n)) + filter_parts.append(f"{concat_inputs}concat=n={n}:v=1:a=1[outv][outa]") + filter_complex = ";".join(filter_parts) + + encoder = encoder_name(use_gpu, codec) + cmd.extend( + [ + "-filter_complex", + filter_complex, + "-map", + "[outv]", + "-map", + "[outa]", + *video_encoder_args(use_gpu, codec), + "-c:a", + "aac", + "-b:a", + "192k", + "-movflags", + "+faststart", + str(output_path), + ] + ) + + print(f"Merging {output_path.name} ({n} sketches, {encoder})...") + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + raise RuntimeError( + f"ffmpeg failed for {output_path.name}:\n{result.stderr[-2000:]}" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Bundle video sketches from a folder into episodes, " + "alternating short and long sketches. " + "Specify either a target length or a target episode count." + ) + ) + parser.add_argument( + "input", + type=Path, + help="Folder containing sketch video files (scanned recursively)", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + default=Path("episodes"), + help="Output folder for merged episodes (default: ./episodes)", + ) + target = parser.add_mutually_exclusive_group() + target.add_argument( + "-t", + "--target-minutes", + type=float, + help="Target episode length in minutes (default: 40 if neither target is set)", + ) + target.add_argument( + "-n", + "--target-episodes", + type=int, + help="Exact number of episodes to create (length derived from total duration)", + ) + parser.add_argument( + "--codec", + choices=("h264", "h265"), + default="h264", + help="Video codec (default: h264). h265 is smaller; use with --gpu for speed", + ) + parser.add_argument( + "--gpu", + action="store_true", + help="Encode with NVIDIA NVENC; falls back to CPU on failure", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the episode plan without merging", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + require_binaries() + + input_dir = args.input.expanduser().resolve() + if not input_dir.is_dir(): + print(f"Error: input is not a directory: {input_dir}", file=sys.stderr) + sys.exit(1) + + if args.target_minutes is not None and args.target_minutes <= 0: + print("Error: --target-minutes must be positive", file=sys.stderr) + sys.exit(1) + if args.target_episodes is not None and args.target_episodes < 1: + print("Error: --target-episodes must be at least 1", file=sys.stderr) + sys.exit(1) + + codec: str = args.codec + use_gpu = False + if args.gpu: + if nvenc_available(codec): + use_gpu = True + print(f"GPU encoding enabled ({encoder_name(True, codec)})") + else: + print( + f"Warning: --gpu requested but {encoder_name(True, codec)} " + "is unavailable; using CPU.", + file=sys.stderr, + ) + + print(f"Scanning {input_dir}...") + sketches = load_sketches(input_dir) + print(f"Found {len(sketches)} sketches\n") + + if args.target_episodes is not None: + try: + episodes, target_seconds = pack_into_n_episodes( + sketches, args.target_episodes + ) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + else: + target_minutes = ( + args.target_minutes if args.target_minutes is not None else 40.0 + ) + target_seconds = target_minutes * 60.0 + episodes = pack_episodes(sketches, target_seconds) + + print_plan(episodes, target_seconds) + + if args.dry_run: + print("Dry run — no files written.") + return + + output_dir = args.output.expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + for index, episode in enumerate(episodes, start=1): + out_path = output_dir / f"aflevering-{index:02d}.mp4" + try: + merge_episode(episode, out_path, use_gpu=use_gpu, codec=codec) + except RuntimeError as exc: + if use_gpu: + print( + f"Warning: GPU encode failed for {out_path.name}; " + "falling back to CPU for remaining episodes.\n" + f"{exc}", + file=sys.stderr, + ) + use_gpu = False + try: + merge_episode(episode, out_path, use_gpu=False, codec=codec) + except RuntimeError as cpu_exc: + print(str(cpu_exc), file=sys.stderr) + sys.exit(1) + else: + print(str(exc), file=sys.stderr) + sys.exit(1) + print(f"Wrote {out_path}") + + print(f"\nDone. {len(episodes)} episode(s) in {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/Python/video-merger/requirements.txt b/Python/video-merger/requirements.txt new file mode 100644 index 0000000..3010898 --- /dev/null +++ b/Python/video-merger/requirements.txt @@ -0,0 +1,2 @@ +# No Python packages required. +# System dependency: ffmpeg (provides ffmpeg + ffprobe on PATH).