Last active
December 24, 2024 06:48
-
-
Save lostfictions/5700848187b8edfb6e45270b462a4534 to your computer and use it in GitHub Desktop.
clip a youtube video with yt-dlp and ffmpeg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
import subprocess | |
import argparse | |
from datetime import datetime | |
from urllib.parse import urlparse | |
from typing import List, Union | |
parser = argparse.ArgumentParser( | |
description="yt-clip: clip videos (from youtube, files, or other websites) by timestamp with the help of yt-dlp and ffmpeg." | |
) | |
parser.add_argument( | |
"url_or_filename", type=str, help="the youtube video url (or local filename)" | |
) | |
parser.add_argument( | |
"start_time", type=str, help="the timestamp where clipping should start" | |
) | |
parser.add_argument( | |
"end_time", type=str, help="the timestamp where clipping should end" | |
) | |
parser.add_argument("outfile", type=str, help="the output filename") | |
# TODO: this currently toggles whether --skip-dash-manifest is used, based on a | |
# recommendation from stackoverflow... but maybe it should be inverted to align | |
# with yt-dlp? i'm not sure what the rationale of skipping it is. | |
parser.add_argument( | |
"--dash", | |
action="store_false", | |
help="use youtube dash manifest (can allow more format selections)", | |
) | |
parser.add_argument( | |
"--source-height", | |
type=int, | |
help="max desired height for the source video (implies dash manifest)", | |
) | |
parser.add_argument( | |
"--target-height", | |
"-t", | |
type=int, | |
help="size of the output video", | |
) | |
parser.add_argument( | |
"--burn-subs", | |
"-b", | |
type=(lambda arg: None if not arg else int(arg) if arg.isdigit() else arg), | |
help="a subtitle track to burn into the video file", | |
) | |
parser.add_argument( | |
"--ytdl-args", help="additional arguments to pass to yt-dlp", default="" | |
) | |
parser.add_argument( | |
"--ffmpeg-args", help="additional arguments to pass to ffmpeg", default="" | |
) | |
parsed_args = parser.parse_args() | |
url_or_filename: str = parsed_args.url_or_filename | |
start: str = parsed_args.start_time | |
end: str = parsed_args.end_time | |
outfile: str = parsed_args.outfile | |
ytdl_args: List[str] = parsed_args.ytdl_args.split() | |
ffmpeg_args: List[str] = parsed_args.ffmpeg_args.split() | |
burn_subs: Union[int, str, None] = parsed_args.burn_subs | |
max_source_height: Union[int, None] = parsed_args.source_height | |
target_height: Union[int, None] = parsed_args.target_height | |
# looks like we need to use the dash manifest if we're specifying a format | |
dash: bool = True if max_source_height else parsed_args.dash | |
# unfortunately ffmpeg's -to argument doesn't seem to work with the streams | |
# yt-dlp gives us, so we need to use -t (which takes a duration rather than an | |
# end timestamp). so... let's calculate that. | |
# ensure the fractional part of the timestamp exists | |
if "." not in start: | |
start = start + ".0" | |
if "." not in end: | |
end = end + ".0" | |
# try to parse with hours, but skip if not present | |
try: | |
start_ts = datetime.strptime(start, "%H:%M:%S.%f") | |
except ValueError: | |
start_ts = datetime.strptime(start, "%M:%S.%f") | |
try: | |
end_ts = datetime.strptime(end, "%H:%M:%S.%f") | |
except ValueError: | |
end_ts = datetime.strptime(end, "%M:%S.%f") | |
end_time = end_ts - start_ts | |
parsed_url = urlparse(url_or_filename) | |
if parsed_url.netloc == "youtube.com": | |
source_type = "youtube" | |
elif not parsed_url.netloc: | |
source_type = "file" | |
else: | |
source_type = "other" | |
if outfile.endswith(".wav") or outfile.endswith(".mp3"): | |
target_type = "audio" | |
elif outfile.endswith(".gif"): | |
target_type = "gif" | |
else: | |
target_type = "video" | |
if source_type == "file": | |
video_or_full_source = url_or_filename | |
audio_source = "" | |
else: | |
# now we're ready to run yt-dlp. | |
ytdl_command = ["yt-dlp", "-g", *ytdl_args] | |
if dash and source_type == "youtube": | |
ytdl_command.append("--youtube-skip-dash-manifest") | |
if max_source_height: | |
ytdl_command.extend(["-f", f"best[height<{max_source_height}]"]) | |
elif target_type == "audio": | |
ytdl_command.extend(["-f", "ba"]) | |
ytdl_command.append(url_or_filename) | |
print(f"running '{' '.join(ytdl_command)}'") | |
output = subprocess.run( | |
ytdl_command, | |
stdout=subprocess.PIPE, | |
text=True, | |
check=True, | |
) | |
# don't throw if we only get one url back | |
video_or_full_source, audio_source, *_ = output.stdout.splitlines() + [""] | |
# finally, we're ready to run ffmpeg. | |
ffmpeg_command = ["ffmpeg", "-ss", start, "-i", video_or_full_source] | |
if audio_source and target_type != "gif": | |
ffmpeg_command.extend(["-ss", start, "-i", audio_source]) | |
ffmpeg_command.extend(["-t", str(end_time)]) | |
# https://stackoverflow.com/questions/59575292/ffmpeg-cut-video-and-burn-subtitle-in-a-single-command/59576487#59576487 | |
if burn_subs is not None: | |
ffmpeg_command.extend(["-copyts"]) | |
if audio_source and target_type == "video": | |
ffmpeg_command.extend(["-map", "0:v", "-map", "1:a"]) | |
if target_type == "video": | |
ffmpeg_command.extend( | |
[ | |
"-c:v", | |
"libx264", | |
"-c:a", | |
"aac", | |
# -pix_fmt required for twitter uploads and other embeds: https://www.wbur.org/citrus/2021/01/20/twitter-videos-for-audio-public-radio | |
"-pix_fmt", | |
"yuv420p", | |
] | |
) | |
if burn_subs is not None: | |
# use embedded subs with provided index | |
if type(burn_subs) is int: | |
ffmpeg_command.extend( | |
[ | |
"-vf", | |
f"subtitles='{url_or_filename}':si={burn_subs}:force_style='Fontname=DejaVu Sans,Fontsize=24'", | |
] | |
) | |
# use sub file. | |
else: | |
ffmpeg_command.extend( | |
[ | |
"-vf", | |
f"subtitles='{burn_subs}':force_style='Fontname=DejaVu Sans,Fontsize=24'", | |
] | |
) | |
if target_height: | |
if target_type == "gif": | |
# you can use this for better gif quantization/quality: | |
# adapted from here: | |
# https://superuser.com/questions/556029/how-do-i-convert-a-video-to-gif-using-ffmpeg-with-reasonable-quality | |
# note that it doesn't always seem to work properly -- maybe it chokes on | |
# long videos? -- and the results may or may not be preferable to not including this filter. | |
# TODO: expose | |
fps = 15 | |
ffmpeg_command.extend( | |
[ | |
"-vf", | |
f"fps={fps},scale={target_height}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", | |
] | |
) | |
else: | |
ffmpeg_command.extend(["-vf", f"scale={target_height}:-1"]) | |
# to burn in subs while clipping, we need to reset the timestamp with `-copyts` and then set it again later. see discussion here: | |
# https://stackoverflow.com/questions/59575292/ffmpeg-cut-video-and-burn-subtitle-in-a-single-command/59576487#59576487 | |
# here's an example invocation that also changes the font and font size: | |
# ffmpeg -ss 1:12:13 -i "New Jack City.mp4" -t 0:00:04.25 -copyts -vf "subtitles=New Jack City.srt:force_style='Fontname=DejaVu Sans,Fontsize=24', scale=480:-1" -c:v libx264 -c:a aac -ss 1:12:12.75 -y chatter.mp4 | |
if burn_subs is not None: | |
ffmpeg_command.extend(["-ss", start]) | |
ffmpeg_command.extend(ffmpeg_args) | |
ffmpeg_command.extend([outfile]) | |
print(f"running '{' '.join(ffmpeg_command)}'") | |
subprocess.run(ffmpeg_command) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment