Last active
February 26, 2025 12:04
-
-
Save justdanpo/da4a046ea587728e2d164957efe7f6ca to your computer and use it in GitHub Desktop.
convert images to timelapse video
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
# convert images to timelapse video | |
# can work with file mask (glob); ffmpeg for windows itself cannot do that | |
# autodetect hardware encoder | |
# interpolate images for smoother video | |
import math | |
import subprocess | |
import glob | |
file_mask = "*.png" | |
frame_rate = 15 # frame rate of original image sequence | |
dest_frame_rate = 30 # interpolate to | |
fade_in = 0.6 # fade time in seconds | |
fade_out = 0.6 # fade time in seconds | |
pix_fmt = "yuv420p" # yuv420p/yuv444p | |
out_file_name = "out.mp4" | |
auto_overwrite = False | |
# resize_to_x = 2560 | |
# resize_to_y = 1080 # 2160 | |
Encoder_settings = { | |
"h264_nvenc": [ | |
"-preset p7", | |
"-tune ull", | |
"-profile:v high", | |
"-b:v 6M", | |
"-maxrate 6M", | |
"-bufsize 12M", | |
"-rc ll_2pass_quality", | |
"-multipass fullres", | |
# strange artifacts on fade in without these: | |
"-bf 3", #-bf <int> E..V....... set maximum number of B-frames between non-B-frames (from -1 to INT_MAX) (default 0) | |
"-g 120", #-g <int> E..V....... set the group of picture (GOP) size (from INT_MIN to INT_MAX) (default 12) | |
], | |
"h264_qsv": ["-global_quality 13"], | |
"libx264": [ | |
"-preset veryslow", | |
"-tune stillimage", | |
"-qp 16", | |
], | |
} | |
################################################################################ | |
def detect_encoders(): | |
default_encoder = "libx264" | |
def get_codec_name(codec_line: str): | |
return codec_line.split()[1] | |
def codec_filter(codec_line: str): | |
if not codec_line.startswith(" V"): | |
return False | |
if not "H.264" in codec_line: | |
return False | |
if default_encoder in codec_line: | |
return False | |
return True | |
def check_codec(codec_name: str): | |
try_encode_virtual_source = subprocess.run( | |
f"ffmpeg -f lavfi -i nullsrc=s=320x128:d=0 -c:v {codec_name} -f null -".split(), | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
) | |
return try_encode_virtual_source.returncode == 0 | |
print("detecting supported encoders") | |
fetch_ffmpeg_encoders = subprocess.run( | |
["ffmpeg", "-hide_banner", "-encoders"], stdout=subprocess.PIPE | |
) | |
if fetch_ffmpeg_encoders.returncode != 0: | |
print("cannot fetch ffmpeg encoder list") | |
exit(1) | |
video_h264_encoders = [ | |
get_codec_name(encoderLine) | |
for encoderLine in fetch_ffmpeg_encoders.stdout.decode("UTF-8").splitlines() | |
if codec_filter(encoderLine) | |
] + [default_encoder] | |
print("examining encoders: ", video_h264_encoders) | |
successfully_executed_codecs = [x for x in video_h264_encoders if check_codec(x)] | |
print("supported encoders list: ", successfully_executed_codecs) | |
return successfully_executed_codecs | |
input_files = [f for f in glob.glob(file_mask)] | |
if len(input_files) == 0: | |
print(f"cannot find files by mask {file_mask}") | |
exit(1) | |
options = [] | |
video_filters = [] | |
supported_encoders = detect_encoders() | |
assert len(supported_encoders) > 0 | |
if auto_overwrite: | |
options.append("-y") | |
options = options + ["-r", f"{frame_rate}"] | |
options = options + ["-i", f'concat:{"|".join(input_files)}'] | |
# add Encoder_settings for specified encoder | |
selected_codec = supported_encoders[0] | |
if not selected_codec in Encoder_settings: | |
print(f"cannot find settings for encoder {selected_codec}") | |
exit(1) | |
else: | |
print(f"using encoder: {selected_codec}") | |
selected_codec_settings = ["-c:v", selected_codec] + Encoder_settings[ | |
selected_codec | |
] | |
for setting in selected_codec_settings: | |
options = options + setting.split() | |
fade_in_frames = fade_in * frame_rate | |
fade_out_frames = fade_out * frame_rate | |
fade_out_start_frame = ( | |
len(input_files) - fade_out_frames - math.ceil(dest_frame_rate / frame_rate) | |
) | |
if fade_in_frames > 0: | |
video_filters.append(f"fade=in:s=0:n={fade_in_frames}") | |
if fade_out_frames > 0: | |
video_filters.append(f"fade=out:s={fade_out_start_frame}:n={fade_out_frames}") | |
if not "resize_to_x" in globals(): | |
resize_to_x = -1 | |
if not "resize_to_y" in globals(): | |
resize_to_y = -1 | |
video_filters.append(f"scale={resize_to_x}:{resize_to_y}") | |
# sometimes video with odd sizes cannot be played; crop images to make sizes even | |
video_filters.append("crop=trunc(iw/2)*2:trunc(ih/2)*2") | |
if frame_rate != dest_frame_rate: | |
video_filters.append(f"minterpolate=fps={dest_frame_rate}:mi_mode=blend") | |
if len(video_filters) > 0: | |
options = options + ["-vf", f'{",".join(video_filters)}'] | |
options = options + ["-pix_fmt", pix_fmt] | |
options.append(out_file_name) | |
encode = subprocess.run(["ffmpeg"] + options) | |
exit(encode.returncode) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment