Skip to content

Instantly share code, notes, and snippets.

@lostfictions
Last active March 12, 2024 08:21
Show Gist options
  • Save lostfictions/5700848187b8edfb6e45270b462a4534 to your computer and use it in GitHub Desktop.
Save lostfictions/5700848187b8edfb6e45270b462a4534 to your computer and use it in GitHub Desktop.
clip a youtube video with yt-dlp and ffmpeg
#!/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":
target_type = "youtube"
elif not parsed_url.netloc:
target_type = "file"
else:
target_type = "other"
if target_type == "file":
video_url = url_or_filename
audio_url = ""
else:
# now we're ready to run yt-dlp.
ytdl_command = ["yt-dlp", "-g", *ytdl_args]
if dash and target_type == "youtube":
ytdl_command.append("--youtube-skip-dash-manifest")
if max_source_height:
ytdl_command.extend(["-f", f"best[height<{max_source_height}]"])
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_url, audio_url, *_ = output.stdout.splitlines() + [""]
# finally, we're ready to run ffmpeg.
ffmpeg_command = ["ffmpeg", "-ss", start, "-i", video_url]
if audio_url and not outfile.endswith(".gif"):
ffmpeg_command.extend(["-ss", start, "-i", audio_url])
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_url and not outfile.endswith(".gif"):
ffmpeg_command.extend(["-map", "0:v", "-map", "1:a"])
if not outfile.endswith(".gif"):
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:
ffmpeg_command.extend(["-vf", f"scale={target_height}:-1"])
# 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
# 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",
# ]
# )
# 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