Created
October 30, 2020 16:41
-
-
Save blackhole077/15b39b88aec3a8f49bbbd5529ecbc170 to your computer and use it in GitHub Desktop.
A small Python Gist that allows for a list of videos (no audio) to be crossfaded automatically, provided they're all in the same folder.
This file contains 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
from os.path import join | |
from subprocess import run, TimeoutExpired, CalledProcessError, check_output | |
from typing import List | |
def run_command(command_args: List[str]=None, timeout_duration: int=30) -> None: | |
""" | |
Run a command via subprocessing. | |
Attempts to run the command given, with a timeout_duration (default 30s). | |
If the command cannot finish executing, then it will return a TimeoutError. | |
Paramters | |
--------- | |
command_args : list(str) | |
A list formatted command, identical to | |
how it would be provided to subprocess.run. | |
timeout_duration : int | |
The amount of time, in seconds, the process is allowed before | |
a TimeoutError is thrown. | |
Returns | |
------- | |
None. | |
Raises | |
------ | |
ValueError | |
This error occurs if no command arguments is given. | |
TimeoutError | |
This error occurs if the process does not finish within the timeout duration. | |
ChildProcessError | |
This error occurs if the process encounters some error during its run (including non-zero exit codes) | |
""" | |
if command_args is None: | |
raise ValueError("Command arguments was None or empty.") | |
try: | |
#Forces whatever called this to wait until this command finishes (to avoid any other issues.) | |
run(command_args, check=True, timeout=timeout_duration) | |
except TimeoutExpired: | |
raise TimeoutError("Command {} has expired after {} seconds".format(command_args, timeout_duration)) | |
except CalledProcessError as _e: | |
raise ChildProcessError(f"Command {' '.join(command_args)} has encountered an error {_e}") | |
def get_media_duration(file_path: str) -> str: | |
""" | |
Fetch information about the media duration. | |
Given the file path to the media (with extension) | |
and additional keyword arguments, return the duration | |
of the media in SECONDS.MICROSECONDS format. | |
Parameters | |
---------- | |
file_path : str | |
The file name of the media, with its extension. | |
This includes the path leading up to the file in question. | |
Returns | |
------- | |
time_str : str | |
A string representation of the media duration. | |
Formatted in SECONDS.MICROSECONDS (4 decimals). | |
""" | |
if file_path: | |
command = [ | |
"ffprobe", "-v", "quiet", "-show_entries", "format=duration", | |
"-of", "csv=p=0", file_path | |
] | |
result = check_output(command).decode('UTF-8').strip()# Decoding as otuput should normally come out encoded as bytes (and contains a newline) | |
return result | |
else: | |
raise ValueError(f"Invalid file path given. Received {file_path}") | |
def crossfade_media_list(videos_to_crossfade_list:List[str], root_directory: str, final_output_name: str) -> str: | |
""" | |
Crossfade a list of videos using FFMPEG. | |
Given a list of videos within a directory, perform multiple | |
crossfade operations that outputs a single video with the | |
name provided in final_output_name. | |
NOTE: xfade (built-in as of FFMPEG 4.3) cannot be used on | |
more than two videos. A previous solution attempted to handle | |
this, but it resulted in videos not having more than one crossfade. | |
NOTE: This function currently only crossfades video. Audio was not | |
considered in the making of this function, as such adjustments may | |
be necessary if audio must be preserved in the operation. | |
Parameters | |
---------- | |
videos_to_crossfade_list : list(str) | |
A list of video file names found inside the root_directory. | |
This function assumes that videos_to_crossfade_list is already | |
sorted or in the desired order. | |
root_directory : str | |
A string representation of the path to the directory holding the videos. | |
final_output_name : str | |
The name of the crossfaded video. | |
Returns | |
------- | |
final_output_name : str | |
The name of the crossfaded video. | |
""" | |
# Start with basic FFMPEG command | |
command_crossfade = ["ffmpeg"] | |
# Insert all videos to be cross-faded together | |
for _video in videos_to_crossfade_list: | |
command_crossfade.append("-i") | |
command_crossfade.append(join(root_directory, _video)) | |
# The expected duration of the crossfade + offset | |
crossfade_duration = 2.0 | |
# Starting tag | |
tag_list = ['[0]'] | |
# List of strings that will be turned into the control scheme for crossfading | |
string_list = [] | |
# Starting index | |
index = 1 | |
cumulative_duration = float(get_media_duration(join(root_directory, videos_to_crossfade_list[0]))) - crossfade_duration | |
_iter = iter(videos_to_crossfade_list[:-1]) | |
# Iteratively build the crossfading command | |
while next(_iter, None) is not None: | |
# Start with the tag of the first video | |
tag_list.append("[V{:02d}]".format(index)) | |
# Append the command and the resulting video tag | |
string_list.append("{}[{}]xfade=transition=fade:duration={}:offset={}{}".format(tag_list[index-1], index, crossfade_duration-1, cumulative_duration, tag_list[index])) | |
# Update the cumulative duration for the video to keep crossfading consistent | |
cumulative_duration += (float(get_media_duration(join(root_directory, videos_to_crossfade_list[index]))) - crossfade_duration) | |
# Update the index for the next tag | |
index += 1 | |
# Combine all statements together with semicolon so FFMPEG understands it | |
_string = ";".join(string_list) | |
# Create a file path for the fully completed video to be placed | |
output_file_path = join(root_directory, final_output_name) | |
# Additional options for running the FFMPEG commmand (can be removed) | |
opt_list = ["-loglevel", "info", "-nostdin", "-hide_banner", "-nostats"] | |
# Controls the resulting quality (can be adjusted for more/less lossy compression) | |
final_section_list = ["-map", tag_list[-1], "-codec:v", "libx264", "-crf", "3", output_file_path] | |
command_crossfade.extend(opt_list) | |
command_crossfade.append("-filter_complex") | |
command_crossfade.append(_string) | |
command_crossfade.extend(final_section_list) | |
run_command(command_crossfade, 600) | |
return final_output_name |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment