Permalink
Cannot retrieve contributors at this time
| import subprocess | |
| import shlex | |
| import sys | |
| import logging | |
| import os | |
| import datetime | |
| import math | |
| import glob | |
| import pipes | |
| from dateutil import relativedelta | |
| ################################## | |
| # Generate tooltip thumbnail images & corresponding WebVTT file for a video (e.g MP4). | |
| # Final product is one *_sprite.jpg file and one *_thumbs.vtt file. | |
| # | |
| # DEPENDENCIES: required: ffmpeg & imagemagick | |
| # optional: sips (comes with MacOSX) - yields slightly smaller sprites | |
| # download ImageMagick: http://www.imagemagick.org/script/index.php OR http://www.imagemagick.org/script/binary-releases.php (on MacOSX: "sudo port install ImageMagick") | |
| # download ffmpeg: http://www.ffmpeg.org/download.html | |
| # jwplayer reference: http://www.longtailvideo.com/support/jw-player/31778/adding-tooltip-thumbnails/ | |
| # | |
| # TESTING NOTES: Tested putting time gaps between thumbnail segments, but had no visual effect in JWplayer, so omitted. | |
| # Tested using an offset so that thumbnail would show what would display mid-way through clip rather than for the 1st second of the clip, but was not an improvement. | |
| ################################## | |
| #TODO determine optimal number of images/segment distance based on length of video? (so longer videos don't have huge sprites) | |
| USE_SIPS = True #True to use sips if using MacOSX (creates slightly smaller sprites), else set to False to use ImageMagick | |
| THUMB_RATE_SECONDS=45 # every Nth second take a snapshot | |
| THUMB_WIDTH=100 #100-150 is width recommended by JWPlayer; I like smaller files | |
| SKIP_FIRST=True #True to skip a thumbnail of second 1; often not a useful image, plus JWPlayer doesn't seem to show it anyway, and user knows beginning without needing preview | |
| SPRITE_NAME = "sprite.jpg" #jpg is much smaller than png, so using jpg | |
| VTTFILE_NAME = "thumbs.vtt" | |
| THUMB_OUTDIR = "thumbs" | |
| USE_UNIQUE_OUTDIR = False #true to make a unique timestamped output dir each time, else False to overwrite/replace existing outdir | |
| TIMESYNC_ADJUST = -.5 #set to 1 to not adjust time (gets multiplied by thumbRate); On my machine,ffmpeg snapshots show earlier images than expected timestamp by about 1/2 the thumbRate (for one vid, 10s thumbrate->images were 6s earlier than expected;45->22s early,90->44 sec early) | |
| logger = logging.getLogger(sys.argv[0]) | |
| logSetup=False | |
| class SpriteTask(): | |
| """small wrapper class as convenience accessor for external scripts""" | |
| def __init__(self,videofile): | |
| self.remotefile = videofile.startswith("http") | |
| if not self.remotefile and not os.path.exists(videofile): | |
| sys.exit("File does not exist: %s" % videofile) | |
| basefile = os.path.basename(videofile) | |
| basefile_nospeed = removespeed(basefile) #strip trailing speed suffix from file/dir names, if present | |
| newoutdir = makeOutDir(basefile_nospeed) | |
| fileprefix,ext = os.path.splitext(basefile_nospeed) | |
| spritefile = os.path.join(newoutdir,"%s_%s" % (fileprefix,SPRITE_NAME)) | |
| vttfile = os.path.join(newoutdir,"%s_%s" % (fileprefix,VTTFILE_NAME)) | |
| self.videofile = videofile | |
| self.vttfile = vttfile | |
| self.spritefile = spritefile | |
| self.outdir = newoutdir | |
| def getVideoFile(self): | |
| return self.videofile | |
| def getOutdir(self): | |
| return self.outdir | |
| def getSpriteFile(self): | |
| return self.spritefile | |
| def getVTTFile(self): | |
| return self.vttfile | |
| def makeOutDir(videofile): | |
| """create unique output dir based on video file name and current timestamp""" | |
| base,ext = os.path.splitext(videofile) | |
| script = sys.argv[0] | |
| basepath = os.path.dirname(os.path.abspath(script)) #make output dir always relative to this script regardless of shell directory | |
| if len(THUMB_OUTDIR)>0 and THUMB_OUTDIR[0]=='/': | |
| outputdir = THUMB_OUTDIR | |
| else: | |
| outputdir = os.path.join(basepath,THUMB_OUTDIR) | |
| if USE_UNIQUE_OUTDIR: | |
| newoutdir = "%s.%s" % (os.path.join(outputdir,base),datetime.datetime.now().strftime("%Y%m%d_%H%M%S")) | |
| else: | |
| newoutdir = "%s_%s" % (os.path.join(outputdir,base),"vtt") | |
| if not os.path.exists(newoutdir): | |
| logger.info("Making dir: %s" % newoutdir) | |
| os.makedirs(newoutdir) | |
| elif os.path.exists(newoutdir) and not USE_UNIQUE_OUTDIR: | |
| #remove previous contents if reusing outdir | |
| files = os.listdir(newoutdir) | |
| print "Removing previous contents of output directory: %s" % newoutdir | |
| for f in files: | |
| os.unlink(os.path.join(newoutdir,f)) | |
| return newoutdir | |
| def doCmd(cmd,logger=logger): #execute a shell command and return/print its output | |
| logger.info( "START [%s] : %s " % (datetime.datetime.now(), cmd)) | |
| args = shlex.split(cmd) #tokenize args | |
| output = None | |
| try: | |
| output = subprocess.check_output(args, stderr=subprocess.STDOUT) #pipe stderr into stdout | |
| except Exception, e: | |
| ret = "ERROR [%s] An exception occurred\n%s\n%s" % (datetime.datetime.now(),output,str(e)) | |
| logger.error(ret) | |
| raise e #todo ? | |
| ret = "END [%s]\n%s" % (datetime.datetime.now(),output) | |
| logger.info(ret) | |
| sys.stdout.flush() | |
| return output | |
| def takesnaps(videofile,newoutdir,thumbRate=None): | |
| """ | |
| take snapshot image of video every Nth second and output to sequence file names and custom directory | |
| reference: https://trac.ffmpeg.org/wiki/Create%20a%20thumbnail%20image%20every%20X%20seconds%20of%20the%20video | |
| """ | |
| if not thumbRate: | |
| thumbRate = THUMB_RATE_SECONDS | |
| rate = "1/%d" % thumbRate # 1/60=1 per minute, 1/120=1 every 2 minutes | |
| cmd = "ffmpeg -i %s -f image2 -bt 20M -vf fps=%s -aspect 16:9 %s/tv%%03d.jpg" % (pipes.quote(videofile), rate, pipes.quote(newoutdir)) | |
| doCmd (cmd) | |
| if SKIP_FIRST: | |
| #remove the first image | |
| logger.info("Removing first image, unneeded") | |
| os.unlink("%s/tv001.jpg" % newoutdir) | |
| count = len(os.listdir(newoutdir)) | |
| logger.info("%d thumbs written in %s" % (count,newoutdir)) | |
| #return the list of generated files | |
| return count,get_thumb_images(newoutdir) | |
| def get_thumb_images(newdir): | |
| return glob.glob("%s/tv*.jpg" % newdir) | |
| def resize(files): | |
| """change image output size to 100 width (originally matches size of video) | |
| - pass a list of files as string rather than use '*' with sips command because | |
| subprocess does not treat * as wildcard like shell does""" | |
| if USE_SIPS: | |
| # HERE IS MAC SPECIFIC PROGRAM THAT YIELDS SLIGHTLY SMALLER JPGs | |
| doCmd("sips --resampleWidth %d %s" % (THUMB_WIDTH," ".join(map(pipes.quote, files)))) | |
| else: | |
| # THIS COMMAND WORKS FINE TOO AND COMES WITH IMAGEMAGICK, IF NOT USING A MAC | |
| doCmd("mogrify -geometry %dx %s" % (THUMB_WIDTH," ".join(map(pipes.quote, files)))) | |
| def get_geometry(file): | |
| """execute command to give geometry HxW+X+Y of each file matching command | |
| identify -format "%g - %f\n" * #all files | |
| identify -format "%g - %f\n" onefile.jpg #one file | |
| SAMPLE OUTPUT | |
| 100x66+0+0 - _tv001.jpg | |
| 100x2772+0+0 - sprite2.jpg | |
| 4200x66+0+0 - sprite2h.jpg""" | |
| geom = doCmd("""identify -format "%%g - %%f\n" %s""" % pipes.quote(file)) | |
| parts = geom.split("-",1) | |
| return parts[0].strip() #return just the geometry prefix of the line, sans extra whitespace | |
| def makevtt(spritefile,numsegments,coords,gridsize,writefile,thumbRate=None): | |
| """generate & write vtt file mapping video time to each image's coordinates | |
| in our spritemap""" | |
| #split geometry string into individual parts | |
| ##4200x66+0+0 === WxH+X+Y | |
| if not thumbRate: | |
| thumbRate = THUMB_RATE_SECONDS | |
| wh,xy = coords.split("+",1) | |
| w,h = wh.split("x") | |
| w = int(w) | |
| h = int(h) | |
| #x,y = xy.split("+") | |
| #======= SAMPLE WEBVTT FILE===== | |
| #WEBVTT | |
| # | |
| #00:00.000 --> 00:05.000 | |
| #/assets/thumbnails.jpg#xywh=0,0,160,90 | |
| # | |
| #00:05.000 --> 00:10.000 | |
| #/assets/preview2.jpg#xywh=160,0,320,90 | |
| # | |
| #00:10.000 --> 00:15.000 | |
| #/assets/preview3.jpg#xywh=0,90,160,180 | |
| # | |
| #00:15.000 --> 00:20.000 | |
| #/assets/preview4.jpg#xywh=160,90,320,180 | |
| #==== END SAMPLE ======== | |
| basefile = os.path.basename(spritefile) | |
| vtt = ["WEBVTT",""] #line buffer for file contents | |
| if SKIP_FIRST: | |
| clipstart = thumbRate #offset time to skip the first image | |
| else: | |
| clipstart = 0 | |
| # NOTE - putting a time gap between thumbnail end & next start has no visual effect in JWPlayer, so not doing it. | |
| clipend = clipstart + thumbRate | |
| adjust = thumbRate * TIMESYNC_ADJUST | |
| for imgnum in range(1,numsegments+1): | |
| xywh = get_grid_coordinates(imgnum,gridsize,w,h) | |
| start = get_time_str(clipstart,adjust=adjust) | |
| end = get_time_str(clipend,adjust=adjust) | |
| clipstart = clipend | |
| clipend += thumbRate | |
| vtt.append("Img %d" % imgnum) | |
| vtt.append("%s --> %s" % (start,end)) #00:00.000 --> 00:05.000 | |
| vtt.append("%s#xywh=%s" % (basefile,xywh)) | |
| vtt.append("") #Linebreak | |
| vtt = "\n".join(vtt) | |
| #output to file | |
| writevtt(writefile,vtt) | |
| def get_time_str(numseconds,adjust=None): | |
| """ convert time in seconds to VTT format time (HH:)MM:SS.ddd""" | |
| if adjust: #offset the time by the adjust amount, if applicable | |
| seconds = max(numseconds + adjust, 0) #don't go below 0! can't have a negative timestamp | |
| else: | |
| seconds = numseconds | |
| delta = relativedelta.relativedelta(seconds=seconds) | |
| return "%02d:%02d:%02d.000" % (delta.hours,delta.minutes, delta.seconds) | |
| def get_grid_coordinates(imgnum,gridsize,w,h): | |
| """ given an image number in our sprite, map the coordinates to it in X,Y,W,H format""" | |
| y = (imgnum - 1)/gridsize | |
| x = (imgnum -1) - (y * gridsize) | |
| imgx = x * w | |
| imgy =y * h | |
| return "%s,%s,%s,%s" % (imgx,imgy,w,h) | |
| def makesprite(outdir,spritefile,coords,gridsize): | |
| """montage _tv*.jpg -tile 8x8 -geometry 100x66+0+0 montage.jpg #GRID of images | |
| NOT USING: convert tv*.jpg -append sprite.jpg #SINGLE VERTICAL LINE of images | |
| NOT USING: convert tv*.jpg +append sprite.jpg #SINGLE HORIZONTAL LINE of images | |
| base the sprite size on the number of thumbs we need to make into a grid.""" | |
| grid = "%dx%d" % (gridsize,gridsize) | |
| cmd = "montage %s/tv*.jpg -tile %s -geometry %s %s" % (pipes.quote(outdir), grid, coords, pipes.quote(spritefile))#if video had more than 144 thumbs, would need to be bigger grid, making it big to cover all our case | |
| doCmd(cmd) | |
| def writevtt(vttfile,contents): | |
| """ output VTT file """ | |
| with open(vttfile,mode="w") as h: | |
| h.write(contents) | |
| logger.info("Wrote: %s" % vttfile) | |
| def removespeed(videofile): | |
| """some of my files are suffixed with datarate, e.g. myfile_3200.mp4; | |
| this trims the speed from the name since it's irrelevant to my sprite names (which apply regardless of speed); | |
| you won't need this if it's not relevant to your filenames""" | |
| videofile = videofile.strip() | |
| speed = videofile.rfind("_") | |
| speedlast = videofile.rfind(".") | |
| maybespeed = videofile[speed+1:speedlast] | |
| try: | |
| int(maybespeed) | |
| videofile = videofile[:speed] + videofile[speedlast:] | |
| except: | |
| pass | |
| return videofile | |
| def run(task, thumbRate=None): | |
| addLogging() | |
| if not thumbRate: | |
| thumbRate = THUMB_RATE_SECONDS | |
| outdir = task.getOutdir() | |
| spritefile = task.getSpriteFile() | |
| #create snapshots | |
| numfiles,thumbfiles = takesnaps(task.getVideoFile(),outdir, thumbRate=thumbRate) | |
| #resize them to be mini | |
| resize(thumbfiles) | |
| #get coordinates from a resized file to use in spritemapping | |
| gridsize = int(math.ceil(math.sqrt(numfiles))) | |
| coords = get_geometry(thumbfiles[0]) #use the first file (since they are all same size) to get geometry settings | |
| #convert small files into a single sprite grid | |
| makesprite(outdir,spritefile,coords,gridsize) | |
| #generate a vtt with coordinates to each image in sprite | |
| makevtt(spritefile,numfiles,coords,gridsize,task.getVTTFile(), thumbRate=thumbRate) | |
| def addLogging(): | |
| global logSetup | |
| if not logSetup: | |
| basescript = os.path.splitext(os.path.basename(sys.argv[0]))[0] | |
| LOG_FILENAME = 'logs/%s.%s.log'% (basescript,datetime.datetime.now().strftime("%Y%m%d_%H%M%S")) #new log per job so we can run this program concurrently | |
| #CONSOLE AND FILE LOGGING | |
| print "Writing log to: %s" % LOG_FILENAME | |
| if not os.path.exists('logs'): | |
| os.makedirs('logs') | |
| logger.setLevel(logging.DEBUG) | |
| handler = logging.FileHandler(LOG_FILENAME) | |
| logger.addHandler(handler) | |
| ch = logging.StreamHandler() | |
| ch.setLevel(logging.DEBUG) | |
| logger.addHandler(ch) | |
| logSetup = True #set flag so we don't reset log in same batch | |
| if __name__ == "__main__": | |
| if not len(sys.argv) > 1 : | |
| sys.exit("Please pass the full path or url to the video file for which to create thumbnails.") | |
| if len(sys.argv) == 3: | |
| THUMB_OUTDIR = sys.argv[2] | |
| videofile = sys.argv[1] | |
| task = SpriteTask(videofile) | |
| run(task) |