diff --git a/Dockerfile b/Dockerfile index 56f111c..8a31961 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim-bookworm +FROM python:3.13-slim-trixie VOLUME /home @@ -6,8 +6,18 @@ COPY video_to_gif.py /home/video_to_gif.py WORKDIR /home +# https://stackoverflow.com/a/52445962 RUN apt update -y && \ apt upgrade -y && \ - apt install ffmpeg -y + apt install ffmpeg -y && \ + apt install curl -y && \ + apt install build-essential -y + +# https://stackoverflow.com/a/49676568 +RUN curl https://sh.rustup.rs -sSf | bash -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + +# https://crates.io/crates/gifski +RUN cargo install gifski ENTRYPOINT ["python", "video_to_gif.py"] \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..4eb4fed --- /dev/null +++ b/Pipfile @@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[requires] +python_version = "3.13" + +[dev-packages] diff --git a/README.md b/README.md index c90e9a1..f6dd44d 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,7 @@ # video-to-gif ## video_to_gif.py usage ``` -usage: video_to_gif.py [-h] [-w WIDTH] [-r FRAMERATE] - [-s {fast_bilinear,bilinear,bicubic,experimental,neighbor,area,bicublin,gauss,sinc,lanczos,spline}] - [-i {none,dup,blend,mci}] [-t TAG] - INPUT [INPUT ...] - -Use ffmpeg to make GIFs from videos - -positional arguments: - INPUT input file, supports passing a glob like /Users/MyName/Videos/*.mov - -options: - -h, --help show this help message and exit - -w, --width WIDTH width of the GIF in pixels, respecting aspect ratio (default: 960) - -r, --framerate FRAMERATE - framerate of GIF (default: 12) - -s, --scaler {fast_bilinear,bilinear,bicubic,experimental,neighbor,area,bicublin,gauss,sinc,lanczos,spline} - scaling algorithm to use (default: lanczos) - -i, --interpolate {none,dup,blend,mci} - interpolation method to use (default: none) - -t, --tag TAG optional tag included in file name - -β€” Be gay and do crime -``` - -## video_to_gif2.py usage -``` -usage: video_to_gif2.py [-h] [-w WIDTH] [-r FRAMERATE] [-e] [-t TAG] INPUT [INPUT ...] +usage: video_to_gif.py [-h] [-w WIDTH] [-r FRAMERATE] [-e] [--no_loop] [-t TAG] INPUT [INPUT ...] Use ffmpeg and gifski to make GIFs from videos @@ -40,13 +14,13 @@ options: -r, --framerate FRAMERATE framerate of GIF (default: 12) -e, --extra increase quality at the expense of file size and encoding time + --no_loop don't loop the animated GIF -t, --tag TAG optional tag included in file name β€” Be gay and do crime ``` ## Docker -Docker only works for the video_to_gif.py script. ```sh docker build -t video_to_gif:latest . docker run --rm -it video_to_gif --help diff --git a/video_to_gif2.py b/_archived/video_to_gif.py similarity index 58% rename from video_to_gif2.py rename to _archived/video_to_gif.py index b38d2b2..4603dcc 100644 --- a/video_to_gif2.py +++ b/_archived/video_to_gif.py @@ -9,24 +9,38 @@ from typing import Any, Dict, List, Tuple def get_args() -> Dict[str, Any]: parser = argparse.ArgumentParser( - prog='video_to_gif2.py', - description='Use ffmpeg and gifski to make GIFs from videos', + prog='video_to_gif.py', + description='Use ffmpeg to make GIFs from videos', epilog='β€” Be gay and do crime') - parser.add_argument('input', type=str, metavar='INPUT', nargs='+', \ + parser.add_argument('input', type=str, metavar='INPUT', nargs='+', help="input file, supports passing a glob like /Users/MyName/Videos/*.mov") - parser.add_argument('-w', '--width', type=str, default='960', \ + parser.add_argument('-w', '--width', type=str, default='960', help='width of the GIF in pixels, respecting aspect ratio (default: 960)') - parser.add_argument('-r', '--framerate', type=str, \ + parser.add_argument('-r', '--framerate', type=str, default='12', help='framerate of GIF (default: 12)') - parser.add_argument('-e', '--extra', default=False, action='store_true', \ - help='increase quality at the expense of file size and encoding time') - parser.add_argument('--no_loop', default=False, action='store_true', \ - help='don\'t loop the animated GIF') - parser.add_argument('-t', '--tag', type=str, default=get_tag(), \ + parser.add_argument('-s', '--scaler', type=str, choices=get_scaling_algorithms(), + default='lanczos', help='scaling algorithm to use (default: lanczos)') + parser.add_argument('-i', '--interpolate', type=str, choices=get_interpolation_methods(), + default='none', help='interpolation method to use (default: none)') + parser.add_argument('-t', '--tag', type=str, default=get_tag(), help='optional tag included in file name') return vars(parser.parse_args()) +def get_scaling_algorithms() -> List[str]: + return ['fast_bilinear', 'bilinear', 'bicubic', 'experimental', 'neighbor', + 'area', 'bicublin', 'gauss', 'sinc', 'lanczos', 'spline'] + + +def get_interpolation_methods() -> List[str]: + return ['none', 'dup', 'blend', 'mci'] + + +# TODO: Figure out how to specify both scaling and dithering algorithms. +def get_dithering_algorithms() -> List[str]: + return ['none', 'auto', 'bayer', 'ed', 'a_dither', 'x_dither'] + + def get_tag() -> str: return ''.join(random.choices(string.ascii_uppercase + string.digits, k=5)) @@ -34,10 +48,7 @@ def get_tag() -> str: def call_ffmpeg(command: List[str]) -> bool: print("ffmpeg", ' '.join(command)) full_command = ["ffmpeg", '-hide_banner', '-loglevel', 'error'] + command - - # TODO: Find a way to avoid shell=True usage. - process = subprocess.run(' '.join(full_command), shell=True) - + process = subprocess.run(full_command) return process.returncode == 0 @@ -45,26 +56,20 @@ def generate_gif_files(inputs: List[str], args: Dict[str, Any]) -> Dict[str, Tup tag = args['tag'] width = args['width'] framerate = args['framerate'] - extra = args['extra'] - no_loop = args['no_loop'] + scaler = args['scaler'] + interpolate = args['interpolate'] - extra_cmd = '' - quality = '90' - if extra: - extra_cmd = '--extra' - quality = '100' - - no_loop_cmd = '' - if no_loop: no_loop_cmd = '--repeat -1' + interpolate_cmd = '' + if interpolate != 'none': + interpolate_cmd = f"minterpolate=fps={ \ + framerate}:mi_mode={interpolate}," output_map = {} for input in inputs: file_name = f"{Path(input).stem}_{tag}.gif" full_path = f"{Path(input).parent}/{file_name}" - command = ['-i', input, '-pix_fmt', 'yuv422p', '-f', 'yuv4mpegpipe', '-', '|', 'gifski', \ - '--width', f"{width}", '-r', f"{framerate}", extra_cmd, '-Q', quality, no_loop_cmd, \ - '-o', f"{full_path}", '-'] - command = list(filter(None, command)) + command = ['-i', input, '-f', 'gif', '-r', f"{framerate}", '-filter_complex', f"{interpolate_cmd}scale={ \ + width}:-1:flags={scaler},split[v1][v2];[v1]palettegen[plt];[v2][plt]paletteuse", f"{full_path}"] success = call_ffmpeg(command) # Tuple of (was it successful?, path of output) output_map[input] = (success, full_path) diff --git a/video_to_gif.py b/video_to_gif.py index 4603dcc..da655da 100644 --- a/video_to_gif.py +++ b/video_to_gif.py @@ -8,68 +8,68 @@ from typing import Any, Dict, List, Tuple def get_args() -> Dict[str, Any]: + """Pull arguments from command line, turn them into a dictionary of """ parser = argparse.ArgumentParser( prog='video_to_gif.py', - description='Use ffmpeg to make GIFs from videos', + description='Use ffmpeg and gifski to make GIFs from videos', epilog='β€” Be gay and do crime') - parser.add_argument('input', type=str, metavar='INPUT', nargs='+', + parser.add_argument('input', type=str, metavar='INPUT', nargs='+', \ help="input file, supports passing a glob like /Users/MyName/Videos/*.mov") - parser.add_argument('-w', '--width', type=str, default='960', + parser.add_argument('-w', '--width', type=str, default='960', \ help='width of the GIF in pixels, respecting aspect ratio (default: 960)') - parser.add_argument('-r', '--framerate', type=str, + parser.add_argument('-r', '--framerate', type=str, \ default='12', help='framerate of GIF (default: 12)') - parser.add_argument('-s', '--scaler', type=str, choices=get_scaling_algorithms(), - default='lanczos', help='scaling algorithm to use (default: lanczos)') - parser.add_argument('-i', '--interpolate', type=str, choices=get_interpolation_methods(), - default='none', help='interpolation method to use (default: none)') - parser.add_argument('-t', '--tag', type=str, default=get_tag(), + parser.add_argument('-e', '--extra', default=False, action='store_true', \ + help='increase quality at the expense of file size and encoding time') + parser.add_argument('--no_loop', default=False, action='store_true', \ + help='don\'t loop the animated GIF') + parser.add_argument('-t', '--tag', type=str, default=get_tag(), \ help='optional tag included in file name') return vars(parser.parse_args()) -def get_scaling_algorithms() -> List[str]: - return ['fast_bilinear', 'bilinear', 'bicubic', 'experimental', 'neighbor', - 'area', 'bicublin', 'gauss', 'sinc', 'lanczos', 'spline'] - - -def get_interpolation_methods() -> List[str]: - return ['none', 'dup', 'blend', 'mci'] - - -# TODO: Figure out how to specify both scaling and dithering algorithms. -def get_dithering_algorithms() -> List[str]: - return ['none', 'auto', 'bayer', 'ed', 'a_dither', 'x_dither'] - - def get_tag() -> str: + """Get a random, five-character string to prevent overwriting files with the same name""" return ''.join(random.choices(string.ascii_uppercase + string.digits, k=5)) def call_ffmpeg(command: List[str]) -> bool: + """Pipe commands to ffmpeg. It unfortunately has to use `shell=True` 🀷""" print("ffmpeg", ' '.join(command)) full_command = ["ffmpeg", '-hide_banner', '-loglevel', 'error'] + command - process = subprocess.run(full_command) + + # TODO: Find a way to avoid shell=True usage. + process = subprocess.run(' '.join(full_command), shell=True) + return process.returncode == 0 def generate_gif_files(inputs: List[str], args: Dict[str, Any]) -> Dict[str, Tuple[bool, str]]: + """Generate GIFs, return a dictionary of β€”the tuple is a boolean + indicating whether creating the GIF was successful""" tag = args['tag'] width = args['width'] framerate = args['framerate'] - scaler = args['scaler'] - interpolate = args['interpolate'] + extra = args['extra'] + no_loop = args['no_loop'] - interpolate_cmd = '' - if interpolate != 'none': - interpolate_cmd = f"minterpolate=fps={ \ - framerate}:mi_mode={interpolate}," + extra_cmd = '' + quality = '90' + if extra: + extra_cmd = '--extra' + quality = '100' + + no_loop_cmd = '' + if no_loop: no_loop_cmd = '--repeat -1' output_map = {} for input in inputs: file_name = f"{Path(input).stem}_{tag}.gif" full_path = f"{Path(input).parent}/{file_name}" - command = ['-i', input, '-f', 'gif', '-r', f"{framerate}", '-filter_complex', f"{interpolate_cmd}scale={ \ - width}:-1:flags={scaler},split[v1][v2];[v1]palettegen[plt];[v2][plt]paletteuse", f"{full_path}"] + command = ['-i', input, '-pix_fmt', 'yuv422p', '-f', 'yuv4mpegpipe', '-', '|', 'gifski', \ + '--width', f"{width}", '-r', f"{framerate}", extra_cmd, '-Q', quality, no_loop_cmd, \ + '-o', f"{full_path}", '-'] + command = list(filter(None, command)) success = call_ffmpeg(command) # Tuple of (was it successful?, path of output) output_map[input] = (success, full_path) @@ -78,6 +78,8 @@ def generate_gif_files(inputs: List[str], args: Dict[str, Any]) -> Dict[str, Tup def make_gifs(args: Dict[str, Any]) -> None: + """Take in command line arguments, do the process to create GIFs, and echo + the files and their statuses""" inputs = args['input'] output_map = generate_gif_files(inputs, args) successes = 0 @@ -91,6 +93,7 @@ def make_gifs(args: Dict[str, Any]) -> None: def main() -> None: + """The main, driving function""" make_gifs(get_args()) return