From b9a5740c989dc2eeb9e691de26fc992a72ca1de4 Mon Sep 17 00:00:00 2001 From: bertieb Date: Thu, 17 Dec 2020 19:18:48 +0000 Subject: [PATCH] "efficient"json.py re-encodeless transition clips TODO: seek to nearest IFrame --- efficientjson.py | 279 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 efficientjson.py diff --git a/efficientjson.py b/efficientjson.py new file mode 100644 index 0000000..11afc00 --- /dev/null +++ b/efficientjson.py @@ -0,0 +1,279 @@ +#!/bin/python +"""jsonclips.py - generate highlights based on JSON + +usage: jsonclips.py clips.json + +JSON file can specify: + - output filename + - source filepath + - start time + - duration + - transition type [default: luma] + - transition duration [default: 30] +""" + +TRANSITION_DURATION = 2 # seconds + +FFMPEG_COMMON_ARGS = ["ffmpeg", "-loglevel", "quiet", "-hide_banner"] + + +def process_highlights(jsonfile): + """Go through highlights, make clips, join to a video""" + import tempfile + + def parse_time(timestring): + from datetime import datetime + """Custom time parsing function""" + if timestring.count(":") == 0: + # probably seconds + formatstring = "%S" + elif timestring.count(":") == 1: + formatstring = "%M:%S" + elif timestring.count(":") == 2: + formatstring = "%H:%M:%S" + + if "." in timestring: + formatstring += ".%f" + + return datetime.strptime(timestring, formatstring) + + def datetime_to_seconds(dt): + """Take a datetime object and convert to seconds""" + from datetime import datetime + + zero = datetime.strptime("0", "%S") + return (dt - zero).seconds + + def make_clips(sources=None, outputdir=None): + """Use ffmpeg to create output from processed JSON""" + import subprocess + import os + + clipnum = 0 + + for source in sources: + # decide on duration vs start+end + if "duration" not in source: + try: + start = parse_time(source["start"]) + end = parse_time(source["end"]) + duration = (end - start).seconds + start = datetime_to_seconds(start) + except ValueError as e: + print("Error: {}".format(e)) + else: + start = source["start"] + duration = source["duration"] + + print("Making {}s clip from {} at {}".format( + duration, source["filepath"], start)) + + ffmpeg_file_args = ["-ss", str(start), + "-i", source["filepath"], + "-t", str(duration), + "-c", "copy", + os.path.join(outputdir, + "clip{}.mkv".format(clipnum))] + + ffmpeg_args = FFMPEG_COMMON_ARGS + ffmpeg_file_args + print("Running ffmpeg with args:\n\n{}". + format(ffmpeg_args)) + subprocess.run(ffmpeg_args) + + clipnum += 1 + + def concat(workingdir=None, outputfile=None): + """Use ffmpeg concat to join clips""" + import subprocess + import os + + joinfile = os.path.join(workingdir, "join.txt") + ffmpeg_concat_args = ["-y", "-safe", "0", + "-f", "concat", + "-i", str(joinfile), + "-c", "copy", + str(outputfile)] + + print(FFMPEG_COMMON_ARGS + ffmpeg_concat_args) + subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_concat_args) + + def parse_json(jsonfile=None): + """Parse global / per-clip options from jsonfile + + Requires: + - outputfile + - list of sources + """ + import json + import sys + + if jsonfile: + with open(jsonfile, "r") as fh: + json_in = json.load(fh) + + if "outputfile" not in json_in: + print("Please specify an outputfile to write to") + sys.exit(1) + + if "sources" not in json_in: + print("Please specify some sources to process") + sys.exit(1) + + return json_in + + def split_first(videofile=None): + """Split first videofile into videofileM + videofileO""" + from basicclips import get_video_duration + import subprocess + + video_length = get_video_duration(videofile) + + # main clip: full length minus half transition duration + ffmpeg_video_args = ["-i", videofile, + "-t", str(video_length - 1), + "-c", "copy", + "{}".format(videofile.replace(".mkv", "M.mkv"))] + + subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args) + + # omega clip: half transition duration + ffmpeg_video_args = ["-i", videofile, + "-ss", str(video_length - 1), + "-c:v", "libx264", "-crf", "20", "-c:a", "copy", + "{}".format(videofile.replace(".mkv", "O.mkv"))] + + subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args) + + def split_last(videofile=None): + """Split last videofile into videofileA + videofileM""" + import subprocess + + # alpha clip: half transition duration + ffmpeg_video_args = ["-i", videofile, + "-t", str(TRANSITION_DURATION/2), + "-c:v", "libx264", "-crf", "20", "-c:a", "copy", + "{}".format(videofile.replace(".mkv", "A.mkv"))] + + subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args) + + # main clip: start at half transition duration, full length + ffmpeg_video_args = ["-i", videofile, + "-ss", str(TRANSITION_DURATION/2), + "-c", "copy", + "{}".format(videofile.replace(".mkv", "M.mkv"))] + + subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args) + + def split_middle(videofile=None): + """Split middle videofile into videofileA + videofileM + videofileO""" + from basicclips import get_video_duration + import subprocess + + video_length = get_video_duration(videofile) + + # alpha clip: half transition duration + ffmpeg_video_args = ["-i", videofile, + "-t", str(TRANSITION_DURATION/2), + "-c:v", "libx264", "-crf", "20", "-c:a", "copy", + "{}".format(videofile.replace(".mkv", "A.mkv"))] + + subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args) + + # main clip: full length minus half transition duration + ffmpeg_video_args = ["-i", videofile, + "-ss", str(TRANSITION_DURATION/2), + "-t", str(video_length - (TRANSITION_DURATION/2)), + "-c", "copy", + "{}".format(videofile.replace(".mkv", "M.mkv"))] + + subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args) + + # omega clip: half transition duration + ffmpeg_video_args = ["-i", videofile, + "-ss", + str(video_length - (TRANSITION_DURATION/2)), + "-c:v", "libx264", "-crf", "20", "-c:a", "copy", + "{}".format(videofile.replace(".mkv", "O.mkv"))] + + subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args) + + def transition_join(video1=None, video2=None, workingdir=None, i=None): + """Join videos 1 and 2 with a transition""" + from moviepy.editor import VideoFileClip + from moviepy.editor import CompositeVideoClip + + tr = TRANSITION_DURATION/2 + + try: + clip1 = VideoFileClip(os.path.join(workingdir, video1)) + except IOError as e: + print("Error making joined clip: {}".format(e)) + import pdb + pdb.set_trace() + + clip2 = VideoFileClip(os.path.join(workingdir, video2)) + + final = CompositeVideoClip([clip1, + clip2.set_start(clip1.end-tr) + .crossfadein(tr)]) + + outputfile = os.path.join(workingdir, "clip{}J.mkv".format(i)) + final.write_videofile(outputfile, codec="libx264", audio_codec="aac") + + return outputfile + + # Start + import os + + if jsonfile: + json_in = parse_json(jsonfile) + + # in temporary dir + with tempfile.TemporaryDirectory() as tmpdir: + # make main clips using codec copy + # from clip0.mkv to clip[len(sources)-1] + make_clips(sources=json_in["sources"], outputdir=tmpdir) + # make subclips (main + transition subclips) + # add to concat 'join.txt' as we go + joinfile = os.path.join(tmpdir, "join.txt") + with open(joinfile, "w") as fh: + for i in range(len(json_in["sources"])): + # First clip needs only main + end + if i == 0: + split_first(os.path.join(tmpdir, "clip0.mkv")) + fh.write("file '{}'\n".format( + os.path.join(tmpdir, "clip0M.mkv"))) + # End clip needs only start + main + elif i == len(json_in["sources"]): + split_last(os.path.join(tmpdir, "clip{}.mkv".format(i))) + # Join previous clip omega + this clip alpha + joined = transition_join("clip{}O.mkv".format(i-1), + "clip{}A.mkv".format(i), + tmpdir, + i) + fh.write("file '{}'\n".format(joined)) + fh.write("file '{}'\n".format( + os.path.join(tmpdir, "clip{}M.mkv".format(i)))) + # Others need start + main + end + else: + split_middle(os.path.join(tmpdir, "clip{}.mkv".format(i))) + # Join previous clip omega + this clip alpha + joined = transition_join("clip{}O.mkv".format(i-1), + "clip{}A.mkv".format(i), + tmpdir, + i) + fh.write("file '{}'\n".format(joined)) + fh.write("file '{}'\n".format( + os.path.join(tmpdir, "clip{}M.mkv".format(i)))) + # Done, join all of the above + print("Making concatenated video: {}".format(json_in["outputfile"])) + with open(os.path.join(tmpdir, "join.txt"), "r") as fh: + for line in fh: + print(line) + concat(workingdir=tmpdir, outputfile=json_in["outputfile"]) + + +if __name__ == "__main__": + import sys + + process_highlights(sys.argv[1])