new arguments

This commit is contained in:
Klemek
2024-03-04 11:31:43 +01:00
parent 31a3f6a18e
commit 3b31144405
2 changed files with 358 additions and 299 deletions
+9 -3
View File
@@ -1,4 +1,6 @@
usage: video-randomizer.py [-h] [-o OUTPUT] [-d DURATION] [-s SAMPLE] [-p HEIGHT] [-w WIDTH] [-f FRAMERATE] [-i IGNORE] [--dry] [-q] [-qf] [--crf CRF] [-r SEED] [--ffmpeg FFMPEG] file [file ...] usage: video-randomizer.py [-h] [-o OUTPUT] [-d DURATION] [-s SAMPLE] [-p HEIGHT] [-w WIDTH] [-f FRAMERATE] [-i IGNORE] [--dry] [-q] [-qf] [--crf CRF] [-r SEED]
[--ffmpeg FFMPEG] [-nc] [-na] [-ab AUDIO_BITRATE]
file [file ...]
randomize videos by taking small random samples and merging them together randomize videos by taking small random samples and merging them together
@@ -14,11 +16,11 @@ optional arguments:
-s SAMPLE, --sample SAMPLE -s SAMPLE, --sample SAMPLE
floating samples duration in seconds (default: 1s) floating samples duration in seconds (default: 1s)
-p HEIGHT, --height HEIGHT -p HEIGHT, --height HEIGHT
output video height (default: 1080p) output video height (default: 1080p if multiple videos)
-w WIDTH, --width WIDTH -w WIDTH, --width WIDTH
output video height (default: auto for 16:9) output video height (default: auto for 16:9)
-f FRAMERATE, --framerate FRAMERATE -f FRAMERATE, --framerate FRAMERATE
output video framerate (default: 30fps) output video framerate (default: 30fps if multiple videos)
-i IGNORE, --ignore IGNORE -i IGNORE, --ignore IGNORE
video input content start/end ignore in % (default: 10) video input content start/end ignore in % (default: 10)
--dry dry mode, do not output video --dry dry mode, do not output video
@@ -27,3 +29,7 @@ optional arguments:
--crf CRF libx264 Constant Rate Factor (default: 23) --crf CRF libx264 Constant Rate Factor (default: 23)
-r SEED, --seed SEED random seed -r SEED, --seed SEED random seed
--ffmpeg FFMPEG ffmpeg binary path (default is found on PATH) --ffmpeg FFMPEG ffmpeg binary path (default is found on PATH)
-nc, --no-convert don't convert videos (default for one video, might fail on multiple)
-na, --no-audio only keep video track
-ab AUDIO_BITRATE, --audio-bitrate AUDIO_BITRATE
audio bitrate in Kbps (default: 128)
+349 -296
View File
@@ -1,296 +1,349 @@
#!/usr/bin/python3 #!/usr/bin/python3
import typing import typing
import cv2 import cv2
import os import os
import math import math
import argparse import argparse
import tempfile import tempfile
import hashlib import hashlib
import random import random
import subprocess import subprocess
import time import time
import sys import sys
import shutil import shutil
CWD = os.path.abspath(os.path.dirname(__file__)) CWD = os.path.abspath(os.path.dirname(__file__))
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="randomize videos by taking small random samples and merging them together", description="randomize videos by taking small random samples and merging them together",
) )
parser.add_argument( parser.add_argument(
"-o", "-o",
"--output", "--output",
type=str, type=str,
default=None, default=None,
help="output video path (default: random_[time].mp4)", help="output video path (default: random_[time].mp4)",
) )
parser.add_argument( parser.add_argument(
"-d", "-d",
"--duration", "--duration",
type=float, type=float,
default=60, default=60,
help="floating duration of output video in seconds (default: 60s)", help="floating duration of output video in seconds (default: 60s)",
) )
parser.add_argument( parser.add_argument(
"-s", "-s",
"--sample", "--sample",
type=float, type=float,
default=1, default=1,
help="floating samples duration in seconds (default: 1s)", help="floating samples duration in seconds (default: 1s)",
) )
parser.add_argument( parser.add_argument(
"-p", "-p",
"--height", "--height",
type=int, type=int,
default=None, default=None,
help="output video height (default: 1080p)", help="output video height (default: 1080p if multiple videos)",
) )
parser.add_argument( parser.add_argument(
"-w", "-w",
"--width", "--width",
type=int, type=int,
default=None, default=None,
help="output video height (default: auto for 16:9)", help="output video height (default: auto for 16:9)",
) )
parser.add_argument( parser.add_argument(
"-f", "-f",
"--framerate", "--framerate",
type=int, type=float,
default=30, default=None,
help="output video framerate (default: 30fps)", help="output video framerate (default: 30fps if multiple videos)",
) )
parser.add_argument( parser.add_argument(
"-i", "-i",
"--ignore", "--ignore",
type=float, type=float,
default=10, default=10,
help="video input content start/end ignore in %% (default: 10)", help="video input content start/end ignore in %% (default: 10)",
) )
parser.add_argument( parser.add_argument(
"--dry", "--dry",
action="store_true", action="store_true",
default=False, default=False,
help="dry mode, do not output video", help="dry mode, do not output video",
) )
parser.add_argument( parser.add_argument(
"-q", "-q",
"--quiet", "--quiet",
action="store_true", action="store_true",
default=False, default=False,
help="silent mode", help="silent mode",
) )
parser.add_argument( parser.add_argument(
"-qf", "-qf",
"--quiet-ffmpeg", "--quiet-ffmpeg",
action="store_true", action="store_true",
default=False, default=False,
help="do not output ffmpeg stdout", help="do not output ffmpeg stdout",
) )
parser.add_argument( parser.add_argument(
"--crf", "--crf",
type=int, type=int,
default=23, default=23,
help="libx264 Constant Rate Factor (default: 23)", help="libx264 Constant Rate Factor (default: 23)",
) )
parser.add_argument( parser.add_argument(
"-r", "-r",
"--seed", "--seed",
type=int, type=int,
default=random.randrange(sys.maxsize), default=random.randrange(sys.maxsize),
help="random seed", help="random seed",
) )
parser.add_argument( parser.add_argument(
"--ffmpeg", "--ffmpeg",
type=str, type=str,
default=None, default=None,
help="ffmpeg binary path (default is found on PATH)", help="ffmpeg binary path (default is found on PATH)",
) )
parser.add_argument("file", type=str, nargs="+", help="input files") parser.add_argument(
return parser.parse_args() "-nc",
"--no-convert",
action="store_true",
# === UTILS === default=False,
help="don't convert videos (default for one video, might fail on multiple)",
)
def get_file_hash(path: str) -> str: parser.add_argument(
with open(path, mode="rb") as f: "-na",
return hashlib.md5(f.read(8192)).hexdigest() "--no-audio",
action="store_true",
default=False,
def get_video_frame_count(path: str) -> int: help="only keep video track",
return cv2.VideoCapture(path).get(cv2.CAP_PROP_FRAME_COUNT) )
parser.add_argument(
"-ab",
def get_timestamp(frame_number: int, framerate: float) -> str: "--audio-bitrate",
t = frame_number / framerate type=int,
return f"{t//60:.0f}:{t%60:.3f}" default=128,
help="audio bitrate in Kbps (default: 128)",
)
def execute(cmd: typing.List[str], silent: bool = False) -> int: parser.add_argument("file", type=str, nargs="+", help="input files")
out = subprocess.DEVNULL if silent else None return parser.parse_args()
popen = subprocess.Popen(cmd, stdout=out, stderr=out, universal_newlines=True)
return popen.wait()
# === UTILS ===
def get_ffmpeg_bin(args: argparse.Namespace) -> str:
if args.ffmpeg and os.path.exists(args.ffmpeg): def get_file_hash(path: str) -> str:
return args.ffmpeg with open(path, mode="rb") as f:
path = shutil.which("ffmpeg") return hashlib.md5(f.read(8192)).hexdigest()
if not path:
print("ffmpeg not found on PATH")
sys.exit(1) def get_video_frame_count(path: str) -> int:
return path return cv2.VideoCapture(path).get(cv2.CAP_PROP_FRAME_COUNT)
def ffmpeg(parameters: typing.List[str], args: argparse.Namespace) -> bool: def get_framerate(path: str, args: argparse.Namespace) -> float:
ffmpeg_bin = get_ffmpeg_bin(args) if not args.no_convert:
cmd = [ffmpeg_bin] + parameters return args.framerate
if not args.quiet: return cv2.VideoCapture(path).get(cv2.CAP_PROP_FPS)
print(f"$ {' '.join(cmd)}")
return execute(cmd, args.quiet or args.quiet_ffmpeg) == 0
def get_timestamp(frame_number: int, framerate: float) -> str:
t = frame_number / framerate
def get_scale(args: argparse.Namespace) -> str: return f"{t//60:.0f}:{t%60:.3f}"
if args.width is None and args.height is None:
return "1920:1080"
elif args.height is None: def execute(cmd: typing.List[str], silent: bool = False) -> int:
return f"{args.width}:{round(args.width * 9 / 16)}" out = subprocess.DEVNULL if silent else None
elif args.width is None: popen = subprocess.Popen(cmd, stdout=out, stderr=out, universal_newlines=True)
return f"{round(args.height * 16 / 9)}:{args.height}" return popen.wait()
else:
return f"{args.width}:{args.height}"
def get_ffmpeg_bin(args: argparse.Namespace) -> str:
if args.ffmpeg and os.path.exists(args.ffmpeg):
# === MAIN === return args.ffmpeg
path = shutil.which("ffmpeg")
if not path:
def get_output_file(args: argparse.Namespace) -> str: print("ffmpeg not found on PATH")
if args.output is not None: sys.exit(1)
return args.output return path
return f"random_{round(time.time())}.mp4"
def ffmpeg(parameters: typing.List[str], args: argparse.Namespace) -> bool:
def get_build_dir(args: argparse.Namespace) -> str: ffmpeg_bin = get_ffmpeg_bin(args)
path = os.path.join( cmd = [ffmpeg_bin] + parameters
os.getcwd(), f"build_{get_scale(args).replace(':','x')}_{args.framerate}fps" if not args.quiet:
) print(f"$ {' '.join(cmd)}")
if not os.path.exists(path): return execute(cmd, args.quiet or args.quiet_ffmpeg) == 0
os.mkdir(path)
return path
def get_scale(args: argparse.Namespace) -> str:
if args.width is None and args.height is None:
def convert_video(in_path: str, out_path: str, args: argparse.Namespace) -> bool: return "1920:1080"
parameters = [ elif args.height is None:
"-y", return f"{args.width}:{round(args.width * 9 / 16)}"
"-f", elif args.width is None:
"mp4", return f"{round(args.height * 16 / 9)}:{args.height}"
"-i", else:
in_path, return f"{args.width}:{args.height}"
"-c:v",
"libx264",
"-vf", def no_convert(args: argparse.Namespace) -> bool:
f"scale={get_scale(args)},fps={args.framerate}", if args.no_convert:
"-crf", return True
str(args.crf), if args.no_audio or args.width is not None or args.height is not None or args.framerate is not None:
"-video_track_timescale", return False
"90000", return len(args.file) == 1
"-an",
out_path,
] # === MAIN ===
return ffmpeg(parameters, args)
def fix_arguments(args: argparse.Namespace) -> argparse.Namespace:
def convert_all_videos(build_dir: str, args: argparse.Namespace) -> typing.List[str]: args.no_convert = no_convert(args)
converted = [] args.framerate = args.framerate if args.framerate is not None else 30
to_convert = [] return args
for path in args.file:
if os.path.exists(path):
output_path = os.path.join(build_dir, get_file_hash(path) + ".mp4") def get_output_file(args: argparse.Namespace) -> str:
if os.path.exists(output_path): if args.output is not None:
converted += [output_path] return args.output
else: return f"random_{round(time.time())}.mp4"
to_convert += [(path, output_path)]
if not args.quiet:
print(f"Found {len(converted) + len(to_convert)} videos") def get_build_dir(args: argparse.Namespace) -> str:
print(f"{len(converted)} already converted") path = os.path.join(
if len(to_convert): os.getcwd(), f"build_{get_scale(args).replace(':','x')}_{args.framerate}fps{'_na' if args.no_audio else ''}"
if not args.quiet: )
print(f"Converting {len(to_convert)} videos...") if not os.path.exists(path) and not args.no_convert:
for i, data in enumerate(to_convert): os.mkdir(path)
in_path, out_path = data return path
result = convert_video(in_path, out_path, args)
if not args.quiet:
print( def convert_video(in_path: str, out_path: str, args: argparse.Namespace) -> bool:
f"[{i + 1} / {len(to_convert)}] {'OK' if result else 'KO'} {in_path} -> {out_path}" parameters = [
) "-y",
if result: "-f",
converted += [out_path] "mp4",
return converted "-i",
in_path,
"-c:v",
def generate_concat_file(videos: typing.List[str], args: argparse.Namespace) -> str: "libx264",
random.seed(args.seed) "-vf",
if not args.quiet: f"scale={get_scale(args)},fps={args.framerate}",
print(f"Random seed: {args.seed}") "-crf",
with tempfile.NamedTemporaryFile(delete=False) as tmp: str(args.crf),
tmp.write("ffconcat version 1.0\n".encode()) "-video_track_timescale",
t = 0 "90000",
while t < args.duration: ]
file = random.choice(videos) if args.no_audio:
framecount = get_video_frame_count(file) parameters += ["-an"]
if framecount > 0: else:
tmp.write(f"file '{file}'\n".encode()) parameters += ["-c:a", "aac", "-b:a", f"{args.audio_bitrate}k"]
inpoint = round( parameters += [out_path]
random.random() * framecount * (1 - args.ignore / 100.0 * 2) return ffmpeg(parameters, args)
)
tmp.write(
f"inpoint {get_timestamp(inpoint, args.framerate)}\n".encode() def convert_all_videos(build_dir: str, args: argparse.Namespace) -> typing.List[str]:
) converted = []
outpoint = inpoint + round(args.sample * args.framerate) to_convert = []
tmp.write( for path in args.file:
f"outpoint {get_timestamp(outpoint, args.framerate)}\n".encode() if os.path.exists(path):
) if args.no_convert:
t += args.sample converted += [os.path.realpath(path)]
if not args.quiet: else:
print(f"FFMPEG concat file: {tmp.name}") output_path = os.path.join(build_dir, get_file_hash(path) + ".mp4")
return tmp.name if os.path.exists(output_path):
converted += [output_path]
else:
def make_output_video( to_convert += [(path, output_path)]
concat_file: str, output_file: str, args: argparse.Namespace if not args.quiet:
) -> None: print(f"Found {len(converted) + len(to_convert)} videos")
parameters = [ print(f"{len(converted)} already converted")
"-y", if len(to_convert):
"-f", if not args.quiet:
"concat", print(f"Converting {len(to_convert)} videos...")
"-safe", for i, data in enumerate(to_convert):
"0", in_path, out_path = data
"-i", result = convert_video(in_path, out_path, args)
concat_file, if not args.quiet:
"-c:v", print(
"libx264", f"[{i + 1} / {len(to_convert)}] {'OK' if result else 'KO'} {in_path} -> {out_path}"
"-async", )
"1", if result:
"-an", converted += [out_path]
output_file, return converted
]
if not ffmpeg(parameters, args):
sys.exit(1) def generate_concat_file(videos: typing.List[str], args: argparse.Namespace) -> str:
random.seed(args.seed)
if not args.quiet:
if __name__ == "__main__": print(f"Random seed: {args.seed}")
args = parse_args() with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp.write("ffconcat version 1.0\n".encode())
output_file = get_output_file(args) t = 0
while t < args.duration:
build_dir = get_build_dir(args) file = random.choice(videos)
framecount = get_video_frame_count(file)
videos = convert_all_videos(build_dir, args) framerate = get_framerate(file, args)
if framecount > 0:
concat_file = generate_concat_file(videos, args) tmp.write(f"file '{file}'\n".encode())
inpoint = round(
if not args.dry: random.random() * framecount * (1 - args.ignore / 100.0 * 2)
make_output_video(concat_file, output_file, args) )
tmp.write(
f"inpoint {get_timestamp(inpoint, framerate)}\n".encode()
)
outpoint = inpoint + round(args.sample * framerate)
tmp.write(
f"outpoint {get_timestamp(outpoint, framerate)}\n".encode()
)
t += args.sample
if not args.quiet:
print(f"FFMPEG concat file: {tmp.name}")
return tmp.name
def make_output_video(
concat_file: str, output_file: str, args: argparse.Namespace
) -> None:
parameters = [
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
concat_file,
"-c:v",
"libx264",
"-async",
"1"
]
if args.no_audio:
parameters += ["-an"]
else:
parameters += ["-c:a", "aac", "-b:a", f"{args.audio_bitrate}k"]
parameters += [output_file]
if not ffmpeg(parameters, args):
sys.exit(1)
if __name__ == "__main__":
args = parse_args()
args = fix_arguments(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)
if not args.dry:
make_output_video(concat_file, output_file, args)