Collection of scripts to make automatic video highlights of varying quality
Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

352 linhas
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])