Collection of scripts to make automatic video highlights of varying quality
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

352 lines
13 KiB

  1. #!/bin/python
  2. """jsonclips.py - generate highlights based on JSON
  3. usage: jsonclips.py clips.json
  4. JSON file can specify:
  5. - output filename
  6. - source filepath
  7. - start time
  8. - duration
  9. - transition type [default: luma]
  10. - transition duration [default: 30]
  11. """
  12. TRANSITION_DURATION = 2 # seconds
  13. FFMPEG_COMMON_ARGS = ["ffmpeg", "-loglevel", "quiet", "-hide_banner"]
  14. def process_highlights(jsonfile):
  15. """Go through highlights, make clips, join to a video"""
  16. import tempfile
  17. def parse_time(timestring):
  18. from datetime import datetime
  19. """Custom time parsing function"""
  20. if timestring.count(":") == 0:
  21. # probably seconds
  22. formatstring = "%S"
  23. elif timestring.count(":") == 1:
  24. formatstring = "%M:%S"
  25. elif timestring.count(":") == 2:
  26. formatstring = "%H:%M:%S"
  27. if "." in timestring:
  28. formatstring += ".%f"
  29. return datetime.strptime(timestring, formatstring)
  30. def datetime_to_seconds(dt):
  31. """Take a datetime object and convert to seconds"""
  32. from datetime import datetime
  33. zero = datetime.strptime("0", "%S")
  34. return (dt - zero).seconds
  35. def make_clips(sources=None, outputdir=None):
  36. """Use ffmpeg to create output from processed JSON"""
  37. import subprocess
  38. import os
  39. clipnum = 0
  40. for source in sources:
  41. # decide on duration vs start+end
  42. if "duration" not in source:
  43. try:
  44. start = parse_time(source["start"])
  45. end = parse_time(source["end"])
  46. duration = (end - start).seconds
  47. start = datetime_to_seconds(start)
  48. except ValueError as e:
  49. print("Error: {}".format(e))
  50. else:
  51. start = source["start"]
  52. duration = source["duration"]
  53. print("Making {}s clip from {} at {}".format(
  54. duration, source["filepath"], start))
  55. ffmpeg_file_args = ["-ss", str(start),
  56. "-i", source["filepath"],
  57. "-t", str(duration),
  58. "-c", "copy",
  59. os.path.join(outputdir,
  60. "clip{}.mkv".format(clipnum))]
  61. ffmpeg_args = FFMPEG_COMMON_ARGS + ffmpeg_file_args
  62. print("Running ffmpeg with args:\n\n{}".
  63. format(ffmpeg_args))
  64. subprocess.run(ffmpeg_args)
  65. clipnum += 1
  66. def concat(workingdir=None, outputfile=None):
  67. """Use ffmpeg concat to join clips"""
  68. import subprocess
  69. import os
  70. joinfile = os.path.join(workingdir, "join.txt")
  71. ffmpeg_concat_args = ["-y", "-safe", "0",
  72. "-f", "concat",
  73. "-i", str(joinfile),
  74. "-c", "copy",
  75. str(outputfile)]
  76. print(FFMPEG_COMMON_ARGS + ffmpeg_concat_args)
  77. subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_concat_args)
  78. def parse_json(jsonfile=None):
  79. """Parse global / per-clip options from jsonfile
  80. Requires:
  81. - outputfile
  82. - list of sources
  83. """
  84. import json
  85. import sys
  86. if jsonfile:
  87. with open(jsonfile, "r") as fh:
  88. json_in = json.load(fh)
  89. if "outputfile" not in json_in:
  90. print("Please specify an outputfile to write to")
  91. sys.exit(1)
  92. if "sources" not in json_in:
  93. print("Please specify some sources to process")
  94. sys.exit(1)
  95. return json_in
  96. def split_first(videofile=None):
  97. """Split first videofile into videofileM + videofileO"""
  98. from basicclips import get_video_duration
  99. import subprocess
  100. video_length = get_video_duration(videofile)
  101. # main clip: full length minus half transition duration
  102. # cut on keyframe
  103. cut_time = nearest_keyframe(videofile, (video_length - 1))
  104. ffmpeg_video_args = ["-i", videofile,
  105. "-t", str(cut_time),
  106. "-c", "copy",
  107. "{}".format(videofile.replace(".mkv", "M.mkv"))]
  108. subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args)
  109. # omega clip: half transition duration
  110. ffmpeg_video_args = ["-i", videofile,
  111. "-ss", str(cut_time),
  112. "-c:v", "libx264", "-crf", "20", "-c:a", "copy",
  113. "{}".format(videofile.replace(".mkv", "O.mkv"))]
  114. subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args)
  115. def split_last(videofile=None):
  116. """Split last videofile into videofileA + videofileM"""
  117. import subprocess
  118. # cut on keyframe
  119. cut_time = nearest_keyframe(videofile, TRANSITION_DURATION/2)
  120. # alpha clip: half transition duration
  121. ffmpeg_video_args = ["-i", videofile,
  122. "-t", str(cut_time),
  123. "-c:v", "libx264", "-crf", "20", "-c:a", "copy",
  124. "{}".format(videofile.replace(".mkv", "A.mkv"))]
  125. subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args)
  126. # main clip: start at half transition duration, full length
  127. ffmpeg_video_args = ["-i", videofile,
  128. "-ss", str(cut_time),
  129. "-c", "copy",
  130. "{}".format(videofile.replace(".mkv", "M.mkv"))]
  131. subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args)
  132. def split_middle(videofile=None):
  133. """Split middle videofile into videofileA + videofileM + videofileO"""
  134. from basicclips import get_video_duration
  135. import subprocess
  136. video_length = get_video_duration(videofile)
  137. # cut on keyframes
  138. cut_time1 = nearest_keyframe(videofile, TRANSITION_DURATION/2)
  139. cut_time2 = nearest_keyframe(videofile, (
  140. video_length - (TRANSITION_DURATION/2)))
  141. # alpha clip: half transition duration
  142. ffmpeg_video_args = ["-i", videofile,
  143. "-t", str(cut_time1),
  144. "-c:v", "libx264", "-crf", "20", "-c:a", "copy",
  145. "{}".format(videofile.replace(".mkv", "A.mkv"))]
  146. subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args)
  147. # main clip: full length minus half transition duration
  148. ffmpeg_video_args = ["-i", videofile,
  149. "-ss", str(cut_time1),
  150. "-t", str(cut_time2),
  151. "-c", "copy",
  152. "{}".format(videofile.replace(".mkv", "M.mkv"))]
  153. subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args)
  154. # omega clip: half transition duration
  155. ffmpeg_video_args = ["-i", videofile,
  156. "-ss",
  157. str(cut_time2),
  158. "-c:v", "libx264", "-crf", "20", "-c:a", "copy",
  159. "{}".format(videofile.replace(".mkv", "O.mkv"))]
  160. subprocess.run(FFMPEG_COMMON_ARGS + ffmpeg_video_args)
  161. def transition_join(video1=None, video2=None, workingdir=None, i=None):
  162. """Join videos 1 and 2 with a transition"""
  163. from moviepy.editor import VideoFileClip
  164. from moviepy.editor import CompositeVideoClip
  165. tr = TRANSITION_DURATION/2
  166. try:
  167. clip1 = VideoFileClip(os.path.join(workingdir, video1))
  168. except IOError as e:
  169. print("Error making joined clip: {}".format(e))
  170. import pdb
  171. pdb.set_trace()
  172. try:
  173. clip2 = VideoFileClip(os.path.join(workingdir, video2))
  174. except IOError as e:
  175. print("Error making joined clip: {}".format(e))
  176. import pdb
  177. pdb.set_trace()
  178. final = CompositeVideoClip([clip1,
  179. clip2.set_start(clip1.end-tr)
  180. .crossfadein(tr)])
  181. outputfile = os.path.join(workingdir, "clip{}J.mkv".format(i))
  182. final.write_videofile(outputfile, codec="libx264", audio_codec="aac")
  183. return outputfile
  184. def nearest_keyframe(videofile=None, checktime=0):
  185. """Find nearest keyframe to checktime using ffprobe
  186. see eg https://superuser.com/a/1426307"""
  187. import subprocess
  188. print("Finding nearest keyframe to {} for {}".format(
  189. checktime, videofile))
  190. if checktime > 15:
  191. skiptime = checktime - 15
  192. else:
  193. skiptime = checktime
  194. frames = []
  195. ffprobe_args = ["ffprobe", "-read_intervals", str(skiptime),
  196. "-select_streams", "v",
  197. "-skip_frame", "nokey", # skip all non keyframes
  198. "-show_frames", "-show_entries",
  199. "frame=pkt_pts_time",
  200. videofile]
  201. # complete = subprocess.run(ffprobe_args, capture_output=True)
  202. proc = subprocess.Popen(ffprobe_args,
  203. stdout=subprocess.PIPE,
  204. stderr=subprocess.DEVNULL,
  205. universal_newlines=True)
  206. try:
  207. lines, error = proc.communicate(timeout=10)
  208. except subprocess.TimoutExpired:
  209. proc.kill()
  210. lines, error = proc.communicate()
  211. print(lines)
  212. for line in lines.split("\n"):
  213. if "pkt_pts_time" in line:
  214. frametime = float(line.split("=")[1])
  215. if round(frametime, 2) != 0.00: # avoid 0s iframe
  216. frames.append(frametime)
  217. if len(frames) > 20:
  218. break
  219. # if "stdout" in complete:
  220. # for line in complete["stdout"].split("\n"):
  221. # if "pkt_pts_time" in line:
  222. # frames.extend(line.split("=")[1])
  223. # get closest value, see https://stackoverflow.com/a/12141207
  224. keyframetime = min(frames, key=lambda x: abs(x-checktime))
  225. return str(round(keyframetime, 2))
  226. # we return the time just before it
  227. # so that ffmpeg seeks to that when
  228. # cutting
  229. # Start
  230. import os
  231. if jsonfile:
  232. json_in = parse_json(jsonfile)
  233. # in temporary dir
  234. with tempfile.TemporaryDirectory() as tmpdir:
  235. # make main clips using codec copy
  236. # from clip0.mkv to clip[len(sources)-1]
  237. make_clips(sources=json_in["sources"], outputdir=tmpdir)
  238. # make subclips (main + transition subclips)
  239. # add to concat 'join.txt' as we go
  240. joinfile = os.path.join(tmpdir, "join.txt")
  241. with open(joinfile, "w") as fh:
  242. for i in range(len(json_in["sources"])):
  243. # First clip needs only main + end
  244. if i == 0:
  245. split_first(os.path.join(tmpdir, "clip0.mkv"))
  246. fh.write("file '{}'\n".format(
  247. os.path.join(tmpdir, "clip0M.mkv")))
  248. # End clip needs only start + main
  249. elif i == len(json_in["sources"]):
  250. split_last(os.path.join(tmpdir, "clip{}.mkv".format(i)))
  251. # Join previous clip omega + this clip alpha
  252. joined = transition_join("clip{}O.mkv".format(i-1),
  253. "clip{}A.mkv".format(i),
  254. tmpdir,
  255. i)
  256. fh.write("file '{}'\n".format(joined))
  257. fh.write("file '{}'\n".format(
  258. os.path.join(tmpdir, "clip{}M.mkv".format(i))))
  259. # Others need start + main + end
  260. else:
  261. split_middle(os.path.join(tmpdir, "clip{}.mkv".format(i)))
  262. # Join previous clip omega + this clip alpha
  263. joined = transition_join("clip{}O.mkv".format(i-1),
  264. "clip{}A.mkv".format(i),
  265. tmpdir,
  266. i)
  267. fh.write("file '{}'\n".format(joined))
  268. fh.write("file '{}'\n".format(
  269. os.path.join(tmpdir, "clip{}M.mkv".format(i))))
  270. # Done, join all of the above
  271. print("Making concatenated video: {}".format(json_in["outputfile"]))
  272. with open(os.path.join(tmpdir, "join.txt"), "r") as fh:
  273. for line in fh:
  274. print(line)
  275. concat(workingdir=tmpdir, outputfile=json_in["outputfile"])
  276. if __name__ == "__main__":
  277. import sys
  278. process_highlights(sys.argv[1])