/src/echonest/video.py

http://echo-nest-remix.googlecode.com/ · Python · 378 lines · 269 code · 53 blank · 56 comment · 56 complexity · 7ff9164e74a6dfcd21ee3596b2167b05 MD5 · raw file

  1. #!/usr/bin/env python
  2. # encoding: utf=8
  3. """
  4. video.py
  5. Framework that turns video into silly putty.
  6. Created by Robert Ochshorn on 2008-5-30.
  7. Refactored by Ben Lacker on 2009-6-18.
  8. Copyright (c) 2008 The Echo Nest Corporation. All rights reserved.
  9. """
  10. from numpy import *
  11. import os
  12. import re
  13. import shutil
  14. import subprocess
  15. import sys
  16. import tempfile
  17. from echonest import audio
  18. from pyechonest import config
  19. class ImageSequence():
  20. def __init__(self, sequence=None, settings=None):
  21. "builds sequence from a filelist, or another ImageSequence object"
  22. self.files, self.settings = [], VideoSettings()
  23. if isinstance(sequence, ImageSequence) or issubclass(sequence.__class__, ImageSequence): #from ImageSequence
  24. self.settings, self.files = sequence.settings, sequence.files
  25. if isinstance(sequence, list): #from filelist
  26. self.files = sequence
  27. if settings is not None:
  28. self.settings = settings
  29. self._init()
  30. def _init(self):
  31. "extra init settings/options (can override...)"
  32. return
  33. def __len__(self):
  34. "how many frames are in this sequence?"
  35. return len(self.files)
  36. def __getitem__(self, index):
  37. index = self.indexvoodo(index)
  38. if isinstance(index, slice):
  39. return self.getslice(index)
  40. else:
  41. raise TypeError("must provide an argument of type 'slice'")
  42. def getslice(self, index):
  43. "returns a slice of the frames as a new instance"
  44. if isinstance(index.start, float):
  45. index = slice(int(index.start*self.settings.fps), int(index.stop*self.settings.fps), index.step)
  46. return self.__class__(self.files[index], self.settings)
  47. def indexvoodo(self, index):
  48. "converts index to frame from a variety of forms"
  49. if isinstance(index, float):
  50. return int(index*self.settings.fps)
  51. return self._indexvoodoo(index)
  52. def _indexvoodoo(self, index):
  53. #obj to slice
  54. if not isinstance(index, slice) and hasattr(index, "start") and hasattr(index, "duration"):
  55. sl = slice(index.start, index.start+index.duration)
  56. return sl
  57. #slice of objs: return slice(start.start, start.end.start+start.end.duration)
  58. if isinstance(index, slice):
  59. if hasattr(index.start, "start") and hasattr(index.stop, "duration") and hasattr(index.stop, "start"):
  60. sl = slice(index.start.start, index.stop.start+index.stop.duration)
  61. return sl
  62. return index
  63. def __add__(self, imseq2):
  64. """returns an ImageSequence with the second seq appended to this
  65. one. uses settings of the self."""
  66. self.render()
  67. imseq2.render() #todo: should the render be applied here? is it destructive? can it render in the new sequence?
  68. return self.__class__(self.files + imseq2.files, self.settings)
  69. def duration(self):
  70. "duration of a clip in seconds"
  71. return len(self) / float(self.settings.fps)
  72. def frametoim(self, index):
  73. "return a PIL image"
  74. return self.__getitem__(index)
  75. def renderframe(self, index, dest=None, replacefileinseq=True):
  76. "renders frame to destination directory. can update sequence with rendered image (default)"
  77. if dest is None:
  78. #handle, dest = tempfile.mkstemp()
  79. dest = tempfile.NamedTemporaryFile().name
  80. #copy file without loading
  81. shutil.copyfile(self.files[index], dest)
  82. #symlink file...
  83. #os.symlink(self.files[index], dest)
  84. if replacefileinseq:
  85. self.files[index] = dest
  86. def render(self, direc=None, pre="image", replacefiles=True):
  87. "renders sequence to stills. can update sequence with rendered images (default)"
  88. if direc is None:
  89. #nothing to render...
  90. return
  91. dest = None
  92. for i in xrange(len(self.files)):
  93. if direc is not None:
  94. dest = os.path.join(direc, pre+'%(#)06d.' % {'#':i})+self.settings.imageformat()
  95. self.renderframe(i, dest, replacefiles)
  96. class EditableFrames(ImageSequence):
  97. "Collection of frames that can be easily edited"
  98. def fadein(self, frames):
  99. "linear fade in"
  100. for i in xrange(frames):
  101. self[i] *= (float(i)/frames) #todo: can i do this without floats?
  102. def fadeout(self, frames):
  103. "linear fade out"
  104. for i in xrange(frames):
  105. self[len(self)-i-1] *= (float(i)/frames)
  106. class VideoSettings():
  107. "simple container for video settings"
  108. def __init__(self):
  109. self.fps = None #SS.MM
  110. self.size = None #(w,h)
  111. self.aspect = None #(x,y) -> x:y
  112. self.bitrate = None #kb/s
  113. self.uncompressed = False
  114. def __str__(self):
  115. "format as ffmpeg commandline settings"
  116. cmd = ""
  117. if self.bitrate is not None:
  118. #bitrate
  119. cmd += " -b "+str(self.bitrate)+"k"
  120. if self.fps is not None:
  121. #framerate
  122. cmd += " -r "+str(self.fps)
  123. if self.size is not None:
  124. #size
  125. cmd += " -s "+str(self.size[0])+"x"+str(self.size[1])
  126. if self.aspect is not None:
  127. #aspect
  128. cmd += " -aspect "+str(self.aspect[0])+":"+str(self.aspect[1])
  129. return cmd
  130. def imageformat(self):
  131. "return a string indicating to PIL the image format"
  132. if self.uncompressed:
  133. return "ppm"
  134. else:
  135. return "jpeg"
  136. class SynchronizedAV():
  137. "SynchronizedAV has audio and video; cuts return new SynchronizedAV objects"
  138. def __init__(self, audio=None, video=None):
  139. self.audio = audio
  140. self.video = video
  141. def __getitem__(self, index):
  142. "Returns a slice as synchronized AV"
  143. if isinstance(index, slice):
  144. return self.getslice(index)
  145. else:
  146. print >> sys.stderr, "WARNING: frame-based sampling not supported for synchronized AV"
  147. return None
  148. def getslice(self, index):
  149. return SynchronizedAV(audio=self.audio[index], video=self.video[index])
  150. def save(self, filename):
  151. audio_filename = filename + '.wav'
  152. audioout = self.audio.encode(audio_filename, mp3=False)
  153. self.video.render()
  154. res = sequencetomovie(filename, self.video, audioout)
  155. os.remove(audio_filename)
  156. return res
  157. def saveAsBundle(self, outdir):
  158. videodir = os.path.join(outdir, "video")
  159. videofile = os.path.join(outdir, "source.flv")
  160. audiofile = os.path.join(outdir, "audio.wav")
  161. os.makedirs(videodir)
  162. # audio.wav
  163. audioout = self.audio.encode(audiofile, mp3=False)
  164. # video frames (some may be symlinked)
  165. self.video.render(dir=videodir)
  166. # video file
  167. print sequencetomovie(videofile, self.video, audioout)
  168. def loadav(videofile, verbose=True):
  169. foo, audio_file = tempfile.mkstemp(".mp3")
  170. cmd = "en-ffmpeg -y -i \"" + videofile + "\" " + audio_file
  171. if verbose:
  172. print >> sys.stderr, cmd
  173. out = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
  174. res = out.communicate()
  175. ffmpeg_error_check(res[1])
  176. a = audio.LocalAudioFile(audio_file)
  177. v = sequencefrommov(videofile)
  178. return SynchronizedAV(audio=a, video=v)
  179. def loadavfrombundle(dir):
  180. # video
  181. videopath = os.path.join(dir, "video")
  182. videosettings = VideoSettings()
  183. videosettings.fps = 25
  184. videosettings.size = (320, 240)
  185. video = sequencefromdir(videopath, settings=videosettings)
  186. # audio
  187. audiopath = os.path.join(dir, "audio.wav")
  188. analysispath = os.path.join(dir, "analysis.xml")
  189. myaudio = audio.LocalAudioFile(audiopath, analysis=analysispath, samplerate=22050, numchannels=1)
  190. return SynchronizedAV(audio=myaudio, video=video)
  191. def loadavfromyoutube(url, verbose=True):
  192. """returns an editable sequence from a youtube video"""
  193. #todo: cache youtube videos?
  194. foo, yt_file = tempfile.mkstemp()
  195. # http://bitbucket.org/rg3/youtube-dl
  196. cmd = "youtube-dl -o " + yt_file + " " + url
  197. if verbose:
  198. print >> sys.stderr, cmd
  199. out = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
  200. res = out.communicate()
  201. return loadav(yt_file)
  202. def youtubedl(url, verbose=True):
  203. """downloads a video from youtube and returns the file object"""
  204. foo, yt_file = tempfile.mkstemp()
  205. # http://bitbucket.org/rg3/youtube-dl
  206. cmd = "youtube-dl -o " + yt_file + " " + url
  207. if verbose:
  208. print >> sys.stderr, cmd
  209. out = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
  210. res = out.communicate()
  211. return yt_file
  212. def getpieces(video, segs):
  213. a = audio.getpieces(video.audio, segs)
  214. newv = EditableFrames(settings=video.video.settings)
  215. for s in segs:
  216. newv += video.video[s]
  217. return SynchronizedAV(audio=a, video=newv)
  218. def sequencefromyoutube(url, settings=None, dir=None, pre="frame-", verbose=True):
  219. """returns an editable sequence from a youtube video"""
  220. #todo: cache youtube videos?
  221. foo, yt_file = tempfile.mkstemp()
  222. # http://bitbucket.org/rg3/youtube-dl
  223. cmd = "youtube-dl -o " + yt_file + " " + url
  224. if verbose:
  225. print >> sys.stderr, cmd
  226. out = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
  227. out.communicate()
  228. return sequencefrommov(yt_file, settings, dir, pre)
  229. def sequencefromdir(dir, ext=None, settings=None):
  230. """returns an image sequence with lexographically-ordered images from
  231. a directory"""
  232. listing = os.listdir(dir)
  233. #remove files without the chosen extension
  234. if ext is not None:
  235. listing = filter(lambda x: x.split(".")[-1]==ext, listing)
  236. listing.sort()
  237. #full file paths, please
  238. listing = map(lambda x: os.path.join(dir, x), listing)
  239. return EditableFrames(listing, settings)
  240. def sequencefrommov(mov, settings=None, direc=None, pre="frame-", verbose=True):
  241. """full-quality video import from stills. will save frames to
  242. tempspace if no directory is given"""
  243. if direc is None:
  244. #make directory for jpegs
  245. direc = tempfile.mkdtemp()
  246. format = "jpeg"
  247. if settings is not None:
  248. format = settings.imageformat()
  249. cmd = "en-ffmpeg -i " + mov + " -an -sameq " + os.path.join(direc, pre + "%06d." + format)
  250. if verbose:
  251. print >> sys.stderr, cmd
  252. out = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
  253. res = out.communicate()
  254. ffmpeg_error_check(res[1])
  255. settings = settingsfromffmpeg(res[1])
  256. seq = sequencefromdir(direc, format, settings)
  257. #parse ffmpeg output for to find framerate and image size
  258. #todo: did this actually happen? errorcheck ffmpeg...
  259. return seq
  260. def sequencetomovie(outfile, seq, audio=None, verbose=True):
  261. "renders sequence to a movie file, perhaps with an audio track"
  262. direc = tempfile.mkdtemp()
  263. seq.render(direc, "image-", False)
  264. cmd = "en-ffmpeg -y " + str(seq.settings) + " -i " + os.path.join(direc, "image-%06d." + seq.settings.imageformat())
  265. if audio:
  266. cmd += " -i " + audio
  267. cmd += " -ab %dk " % getattr(config, 'MP3_BITRATE', '64')
  268. cmd += " -sameq " + outfile
  269. if verbose:
  270. print >> sys.stderr, cmd
  271. out = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
  272. res = out.communicate()
  273. ffmpeg_error_check(res[1])
  274. def convertmov(infile, outfile=None, settings=None, verbose=True):
  275. """
  276. Converts a movie file to a new movie file with different settings.
  277. """
  278. if settings is None:
  279. settings = VideoSettings()
  280. settings.fps = 29.97
  281. settings.size = (320, 180)
  282. settings.bitrate = 200
  283. if not isinstance(settings, VideoSettings):
  284. raise TypeError("settings arg must be a VideoSettings object")
  285. if outfile is None:
  286. foo, outfile = tempfile.mkstemp(".flv")
  287. cmd = "en-ffmpeg -y -i " + infile + " " + str(settings) + " -sameq " + outfile
  288. if verbose:
  289. print >> sys.stderr, cmd
  290. out = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)
  291. res = out.communicate()
  292. ffmpeg_error_check(res[1])
  293. return outfile
  294. def settingsfromffmpeg(parsestring):
  295. """takes ffmpeg output and returns a VideoSettings object mimicking
  296. the input video"""
  297. settings = VideoSettings()
  298. parse = parsestring.split('\n')
  299. for line in parse:
  300. if "Stream #0.0" in line and "Video" in line:
  301. segs = line.split(", ")
  302. for seg in segs:
  303. if re.match("\d*x\d*", seg):
  304. #dimensions found
  305. settings.size = map(int, seg.split(" ")[0].split('x'))
  306. if "DAR " in seg:
  307. #display aspect ratio
  308. start = seg.index("DAR ")+4
  309. end = seg.index("]", start)
  310. settings.aspect = map(int, seg[start:end].split(":"))
  311. elif re.match("(\d*\.)?\d+[\s]((fps)|(tbr)|(tbc)).*", seg):
  312. #fps found
  313. #todo: what's the difference between tb(r) and tb(c)?
  314. settings.fps = float(seg.split(' ')[0])
  315. elif re.match("\d*.*kb.*s", seg):
  316. #bitrate found. assume we want the same bitrate
  317. settings.bitrate = int(seg[:seg.index(" ")])
  318. return settings
  319. def ffmpeg_error_check(parsestring):
  320. parse = parsestring.split('\n')
  321. for num, line in enumerate(parse):
  322. if "Unknown format" in line or "error occur" in line:
  323. raise RuntimeError("en-ffmpeg conversion error:\n\t" + "\n\t".join(parse[num:]))