Skip to content

Instantly share code, notes, and snippets.

@SavinaRoja
Last active August 31, 2017 12:48
Show Gist options
  • Save SavinaRoja/a6fc46e02faecd9495fb7010f4263176 to your computer and use it in GitHub Desktop.
Save SavinaRoja/a6fc46e02faecd9495fb7010f4263176 to your computer and use it in GitHub Desktop.
Some updates to Sebastien Charlemagne's code for flexibility and maintainability
#!/usr/bin/env python3
"""
Concat - a tool for efficiently concatenating video inputs and combining with
an audio file
Usage:
concat <json-input> [options] <output>
concat (-h | --help)
concat --version
Options:
-a --audio=AUDIOPATH Specify a file path to the audio file other than the
default ./audio.mp3 relative to JSON input.
-k --keep-intermediates Don't delete all intermediate (temporary) files.
-c --crf=CRF Set the libx264 CRF value. [default: 0]
-p --preset=PRESET Set the libx264 --preset [default: ultrafast]
-P --pix-format=PIXFMT Set the pixel format for the output video stream.
[default: yuv444p]
-s --size=WIDTHXHEIGHT Set the output video size in width x height, like
"1280x720". [default: 1280x720]
-m --multiprocess=NPOOL Use a multiprocess pool to produce intermediates from
inputs in parallel. Can produce greater CPU
saturation and give shorter execution times. Provide
an integer value, 2 is a good start. [default: 0]
Authors:
Paul Barton (SavinaRoja)
Sebastien Charlemagne (scharlem)
"""
#NOTES:
#Significant thanks to Sebastien for doing the heavy lifting to configure the
#scale filter. This script is a little slower as it spends a little more time
#in the Python environment than FFMPEG. Let's review the changes:
# * Ultimately this should be more maintainable. It was a real bear to dig
# into the first version (but it was technically brilliant!)
# * I gave it a flexible interface with configurable options, this will help to
# easily test new options. The output can be named
# * This script ought to be fully cross platform.
# * If you don't like my defaults, you can change them easily!
#
#On lossless, I think that unless the disk space is a severe limitation, it
#should be worth doing lossless output. Downstream operations can then all be
#performed at that level before rendering to whatever target container/codec
#and there will only ever be one quality-reducing step...
#stdlib imports
from collections import OrderedDict
import json
import os
import shutil
import subprocess
from multiprocessing import Pool
from functools import partial
#External library imports
from docopt import docopt # For the easy interface
__version__ = '0.1.0'
if __name__ == '__main__':
args = docopt(__doc__, version='Concat {}'.format(__version__))
WIDTH, HEIGHT = args['--size'].split('x')
#All file paths in the JSON will be considered to be relative to JSON file
base_dir = os.path.abspath(os.path.split(args['<json-input>'])[0])
intermediates_dir = os.path.join(base_dir, 'intermediates')
if not os.path.exists(intermediates_dir):
os.mkdir(intermediates_dir)
#the audio.mp3 file is assumed to be at the same level as the .json unless
#otherwise specified
if args['--audio'] is not None:
audio_file_path = os.path.abspath(args['--audio'])
else:
audio_file_path = os.path.join(base_dir, 'audio.mp3')
#Parse the JSON with OrderedDict to easily keep the order
with open(args['<json-input>']) as inp:
json_inp = json.load(inp, object_pairs_hook=OrderedDict)
intermediates = []
commands = []
media = json_inp['mediaSources']
for index, start_point in enumerate(media):
index_str = str(index)
attrs = media[start_point]['attributes']
#Perform some basic filename and path operations
inptname = attrs['fileName']
inptname_root, inptname_ext = os.path.splitext(inptname)
#create a normalized, absolute path
inptpath = os.path.normpath(os.path.join(base_dir, inptname))
#Create name and paths for intermediate file
intermediate_name = 'intermediate-{}.mkv'.format(index)
intermediate_filepath = os.path.join(intermediates_dir, intermediate_name)
intermediates.append(intermediate_filepath)
#Grab and do some calculations with the attributes
in_stream_start = str(media[start_point]['attributes']['trim'] / 1000.0)
duration = media[start_point]['attributes']['duration'] / 1000.0
#duration_str = str(duration)
is_video = media[start_point]['attributes']['animated']
my_filter = """\
scale=(iw*sar)*min({width}/(iw*sar)\,{height}/ih):ih*min({width}/(iw*sar)\,\
{height}/ih),pad={width}:{height}:({width}-iw*min({width}/iw\,{height}/ih))/2:(\
{height}-ih*min({width}/iw\,{height}/ih))/2\
""".format(**{'width': WIDTH, 'height': HEIGHT, 'index': str(index)})
to_intermediate_cmd = ['ffmpeg',
'-ss', in_stream_start, '-t', str(duration),
'-i', inptpath,
'-lavfi']
if is_video:
#use ffprobe to get duration of the stream
ffprobe_duration = subprocess.check_output(['ffprobe',
'-v','error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
inptpath])
if ffprobe_duration[0:3] == "N/A": #Handle some odd cases where duration is unknown
actualDuration=0
else:
ffprobe_duration=float(ffprobe_duration)
#Stream is shorter than expected. Just adjust myfilter to have it the right length
#this is done by creating a null src with the right duration then overlaying it
#with our shorter stream. the Overlay filter is responsible to still the last
#frame
if ffprobe_duration < duration:
filter_add = """\
[short];nullsrc=size={size}:duration={duration}[BG];[BG][short]overlay\
""".format(**{'size': args['--size'], 'duration': str(duration), 'index': str(index)})
my_filter += filter_add
#The input is not a video, thus a still image
else:
#insert the loop on the still image input
to_intermediate_cmd = to_intermediate_cmd[:1] + ['-loop', '1'] + to_intermediate_cmd[1:]
#Now that we have completed manipulating the video filter, add it to the command
#my_filter = my_filter + '"'
to_intermediate_cmd.append(my_filter)
#Time to add our output specifications
output_args = ['-c:a', 'copy',
'-pix_fmt', args['--pix-format'],
'-c:v', 'libx264',
'-crf', args['--crf'],
'-preset', args['--preset'],
intermediate_filepath, '-y']
to_intermediate_cmd += output_args
commands.append(to_intermediate_cmd)
#subprocess.run(to_intermediate_cmd)
my_run = partial(subprocess.run,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
if args['--multiprocess'] not in ['0', '1']:
n = int(args['--multiprocess'])
with Pool(n) as p:
for command in commands:
p.apply_async(my_run, (command,))
p.close()
p.join()
else:
for command in commands:
my_run(command)
#So now that we have pegged the inputs to the target specification, we will
#now concatenate them with the concatenation demuxer then lay on the audio
demuxer_file = os.path.join(intermediates_dir, 'demux.txt')
tmp_output = os.path.join(intermediates_dir, 'tmp.mkv')
#Write the concat demuxer file then use it
with open(demuxer_file, 'w') as outp:
for item in intermediates:
outp.write('file ' + item + '\n')
subprocess.run(['ffmpeg',
'-f', 'concat', '-safe', '0',
'-i', demuxer_file,
'-c', 'copy', '-shortest',
tmp_output, '-y'])
#Take the audio-less temp file and the audio to produce final input
subprocess.run(['ffmpeg', '-i', tmp_output, '-i', audio_file_path,
'-acodec', 'copy',
'-vcodec', 'copy',
'-shortest', args['<output>'], '-y'])
#If we got this far, then things probably went all right! Time to tear down
#the intermediates directory unless disabled
if not args['--keep-intermediates']:
shutil.rmtree(intermediates_dir)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment