Skip to content

Instantly share code, notes, and snippets.

@blackhole077
Created October 30, 2020 16:41
Show Gist options
  • Save blackhole077/15b39b88aec3a8f49bbbd5529ecbc170 to your computer and use it in GitHub Desktop.
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.
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