From 8449b8cdf29215e91b3634dab52e994cf1d68d35 Mon Sep 17 00:00:00 2001 From: Klemek Date: Fri, 9 Feb 2024 13:00:40 +0100 Subject: [PATCH] initial commit --- .gitignore | 6 ++ README.txt | 28 +++++ requirements.txt | 1 + video-randomizer | 1 + video-randomizer.py | 254 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 .gitignore create mode 100644 README.txt create mode 100644 requirements.txt create mode 120000 video-randomizer create mode 100644 video-randomizer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..17046b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build_* +raw +.vscode +.pytest_cache +*.mp4 +*.zip \ No newline at end of file diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..08ed267 --- /dev/null +++ b/README.txt @@ -0,0 +1,28 @@ +usage: video-randomizer [-h] [-o OUTPUT] [-d DURATION] [-s SAMPLE] [-p HEIGHT] [-w WIDTH] [-f FRAMERATE] [-i IGNORE] [--dry] [-q] [-qf] [--crf CRF] [-r SEED] file [file ...] + +randomize videos by taking small random samples and merging them together + +positional arguments: + file input files + +optional arguments: + -h, --help show this help message and exit + -o OUTPUT, --output OUTPUT + output video path (default: random_[time].mp4) + -d DURATION, --duration DURATION + floating duration of output video in seconds (default: 60s) + -s SAMPLE, --sample SAMPLE + floating samples duration in seconds (default: 1s) + -p HEIGHT, --height HEIGHT + output video height (default: 1080p) + -w WIDTH, --width WIDTH + output video height (default: auto for 16:9) + -f FRAMERATE, --framerate FRAMERATE + output video framerate (default: 30fps) + -i IGNORE, --ignore IGNORE + video input content start/end ignore in % (default: 10) + --dry dry mode, do not output video + -q, --quiet silent mode + -qf, --ffmpeg-quiet do not output ffmpeg stdout + --crf CRF libx264 Constant Rate Factor (default: 23) + -r SEED, --seed SEED random seed \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..acffc3a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +python-opencv diff --git a/video-randomizer b/video-randomizer new file mode 120000 index 0000000..c7a4be4 --- /dev/null +++ b/video-randomizer @@ -0,0 +1 @@ +video-randomizer.py \ No newline at end of file diff --git a/video-randomizer.py b/video-randomizer.py new file mode 100644 index 0000000..16d3fb6 --- /dev/null +++ b/video-randomizer.py @@ -0,0 +1,254 @@ +#!/usr/bin/python3 + +import cv2 +import os +import math +import argparse +from typing import * +import tempfile +import hashlib +import random +import subprocess +import time +import sys + +CWD = os.path.abspath(os.path.dirname(__file__)) + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="video-randomizer", + description="randomize videos by taking small random samples and merging them together", + ) + parser.add_argument( + "-o", + "--output", + type=str, + default=None, + help="output video path (default: random_[time].mp4)", + ) + parser.add_argument( + "-d", + "--duration", + type=float, + default=60, + help="floating duration of output video in seconds (default: 60s)", + ) + parser.add_argument( + "-s", + "--sample", + type=float, + default=1, + help="floating samples duration in seconds (default: 1s)", + ) + parser.add_argument( + "-p", + "--height", + type=int, + default=None, + help="output video height (default: 1080p)", + ) + parser.add_argument( + "-w", + "--width", + type=int, + default=None, + help="output video height (default: auto for 16:9)", + ) + parser.add_argument( + "-f", + "--framerate", + type=int, + default=30, + help="output video framerate (default: 30fps)", + ) + parser.add_argument( + "-i", + "--ignore", + type=float, + default=10, + help="video input content start/end ignore in %% (default: 10)", + ) + parser.add_argument( + "--dry", + action="store_true", + default=False, + help="dry mode, do not output video", + ) + parser.add_argument( + "-q", + "--quiet", + action="store_true", + default=False, + help="silent mode", + ) + parser.add_argument( + "-qf", + "--ffmpeg-quiet", + action="store_true", + default=False, + help="do not output ffmpeg stdout", + ) + parser.add_argument( + "--crf", + type=int, + default=23, + help="libx264 Constant Rate Factor (default: 23)", + ) + parser.add_argument( + "-r", + "--seed", + type=int, + default=random.randrange(sys.maxsize), + help="random seed", + ) + parser.add_argument( + "file", + type=str, + nargs='+', + help='input files' + ) + return parser.parse_args() + + +# === UTILS === + + +def get_file_hash(path: str) -> str: + with open(path, mode="rb") as f: + return hashlib.md5(f.read(8192)).hexdigest() + + +def get_video_frame_count(path: str) -> int: + return cv2.VideoCapture(path).get(cv2.CAP_PROP_FRAME_COUNT) + + + +def get_timestamp(frame_number: int, framerate: float) -> str: + t = frame_number / framerate + return f"{t//60:.0f}:{t%60:.3f}" + + +def execute(cmd: List[str]) -> Generator[str, None, None]: + popen = subprocess.Popen(cmd, stdout=subprocess.PIPE, universal_newlines=True) + if popen.stdout is not None: + for stdout_line in iter(popen.stdout.readline, ""): + yield stdout_line + popen.stdout.close() + return_code = popen.wait() + if return_code: + raise subprocess.CalledProcessError(return_code, cmd) + +def execute_and_print(cmd: List[str]) -> None: + for line in execute(cmd): + print(line) + + +def execute_with_args(cmd: List[str], args: argparse.Namespace) -> bool: + if not args.quiet: + print(f"$ {' '.join(cmd)}") + try: + if args.quiet or args.ffmpeg_quiet: + execute(cmd) + else: + execute_and_print(cmd) + return True + except subprocess.CalledProcessError: + return False + + +def get_scale(args: argparse.Namespace) -> str: + if args.width is None and args.height is None: + return "1920:1080" + elif args.height is None: + return f"{args.width}:{round(args.width * 9 / 16)}" + elif args.width is None: + return f"{round(args.height * 16 / 9)}:{args.height}" + else: + return f"{args.width}:{args.height}" + + +# === MAIN === + + +def get_output_file(args: argparse.Namespace) -> str: + if args.output is not None: + return args.output + return f"random_{round(time.time())}.mp4" + + +def get_build_dir(args: argparse.Namespace) -> str: + path = os.path.join(os.getcwd(), f"build_{get_scale(args).replace(':','x')}_{args.framerate}fps") + if not os.path.exists(path): + os.mkdir(path) + return path + + +def convert_video(in_path: str, out_path: str, args: argparse.Namespace) -> bool: + cmd = [ + 'ffmpeg', '-y', '-f', 'mp4', '-i', in_path, '-c:v', 'libx264', '-vf', f'scale={get_scale(args)},fps={args.framerate}', '-crf', str(args.crf), '-video_track_timescale', '90000', '-an', out_path + ] + return execute_with_args(cmd, args) + + +def convert_all_videos(build_dir: str, args: argparse.Namespace) -> List[str]: + converted = [] + to_convert = [] + for path in args.file: + if os.path.exists(path): + output_path = os.path.join(build_dir, get_file_hash(path) + ".mp4") + if os.path.exists(output_path): + converted += [output_path] + else: + to_convert += [(path, output_path)] + if not args.quiet: + print(f"Found {len(converted) + len(to_convert)} videos") + print(f"{len(converted)} already converted") + if len(to_convert): + if not args.quiet: + print(f"Converting {len(to_convert)} videos...") + for i, data in enumerate(to_convert): + in_path, out_path = data + result = convert_video(in_path, out_path, args) + if not args.quiet: + print(f"[{i + 1} / {len(to_convert)}] {'OK' if result else 'KO'} {in_path} -> {out_path}") + if result: + converted += [out_path] + return converted + + +def generate_concat_file(videos: List[str], args: argparse.Namespace) -> str: + random.seed(args.seed) + print(f"Random seed: {args.seed}") + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write("ffconcat version 1.0\n".encode()) + t = 0 + while t < args.duration: + file = random.choice(videos) + framecount = get_video_frame_count(file) + if framecount > 0: + tmp.write(f"file '{file}'\n".encode()) + inpoint = round(random.random() * framecount * (1 - args.ignore / 100.0 * 2)) + tmp.write(f"inpoint {get_timestamp(inpoint, args.framerate)}\n".encode()) + outpoint = inpoint + round(args.sample * args.framerate) + tmp.write(f"outpoint {get_timestamp(outpoint, args.framerate)}\n".encode()) + t += args.sample + return tmp.name + +def make_output_video(concat_file: str, output_file: str, args: argparse.Namespace) -> None: + cmd = [ + 'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_file, '-c:v', 'libx264', '-async', '1', '-an', output_file + ] + execute_with_args(cmd, args) + +if __name__ == '__main__': + args = parse_args() + + output_file = get_output_file(args) + + build_dir = get_build_dir(args) + + videos = convert_all_videos(build_dir, args) + + concat_file = generate_concat_file(videos, args) + + make_output_video(concat_file, output_file, args)