diff --git a/.gitignore b/.gitignore index c1fc7b1..2c4806b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ build/ src/ ly2video.tmp/ README.html +*~ +*.pyc +.project +.settings +.pydevproject diff --git a/doc/slideshow.txt b/doc/slideshow.txt new file mode 100644 index 0000000..64b7906 --- /dev/null +++ b/doc/slideshow.txt @@ -0,0 +1,22 @@ + +--slide-show + +The option --slide-show adds the possibility to display a slide shown up to the score. +This slide show is composed of a set of png files + - sharing a common prefix (for example "/path/to/dir/slide-") + - having each one a suffix with the format "0000.0000.png", giving + the offset (in quarters) when the image must be displayed. + +For example, given these files: + /path/to/dir/slide-0000.0000.png + /path/to/dir/slide-0000.0050.png + /path/to/dir/slide-0008.0025.png + +with the command: + ly2video --slide-show "/path/to/dir/slide-" ... + +The first file will be displayed at the begining of the score. +The second one replaces the first one at offset 0.5000 (one eighth after the start), and so on. + + + diff --git a/ly2video.py b/ly2video.py index 6029401..8768a9b 100755 --- a/ly2video.py +++ b/ly2video.py @@ -4,6 +4,7 @@ # ly2video - generate performances video from LilyPond source files # Copyright (C) 2012 Jiri "FireTight" Szabo # Copyright (C) 2012 Adam Spiers +# Copyright (C) 2014 Emmanuel Leguy # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -36,17 +37,18 @@ import pipes from collections import namedtuple from distutils.version import StrictVersion -from optparse import OptionParser +from argparse import ArgumentParser from struct import pack from PIL import Image, ImageDraw, ImageFont from ly.tokenize import MusicTokenizer, Tokenizer import ly.tools import midi +from utils import * +from video import * from pprint import pprint, pformat -DEBUG = False # --debug sets to True GLOBAL_STAFF_SIZE = 20 @@ -262,43 +264,39 @@ def getLeftmostGrobsByMoment(output, dpi, leftPaperMarginPx): return groblist -def findTopStaffLine(image, lineLength): - """ - Returns the coordinates of the left-most pixel in the top line of - the first staff in the image. +def getMeasuresIndices(output, dpi, leftPaperMarginPx): + ret = [] + ret.append(leftPaperMarginPx) + lines = output.split('\n') - FIXME: The code assumes that the first staff is not indented - further right than subsequent staffs. + for line in lines: + if not line.startswith('ly2videoBar: '): + continue - Params: - - image: image with staff lines - - lineLength: needed length of line to accept it as staff line - """ - # position of the first line on image - firstLinePos = (-1, -1) - - width, height = image.size - - # Start searching at the hard left but allow for a left margin. - for x in xrange(width): - for y in xrange(height): - for length in xrange(lineLength): - # testing color of pixels in range (startPos, startPos + lineLength) - if image.getpixel((x + length, y)) == (255,255,255): - # if it's white then it's not a staff line - firstLinePos = (-1, -1) - break - else: - # else it can be - firstLinePos = (x, y) - # when have a valid position, break out - if firstLinePos != (-1, -1): - break - if firstLinePos != (-1, -1): - break + m = re.match('^ly2videoBar:\\s+' + # X-extents + '\\(\\s*(-?\\d+\\.\\d+),\\s*(-?\\d+\\.\\d+)\\s*\\)' + # delimiter + '\\s+@\\s+' + # moment + '(-?\\d+\\.\\d+)' + '$', line) + if not m: + bug("Failed to parse ly2video line:\n%s" % line) + left, right, moment = m.groups() + + + left = float(left) + right = float(right) + centre = (left + right) / 2 + moment = float(moment) + x = int(round(staffSpacesToPixels(centre, dpi))) + leftPaperMarginPx - progress("First staff line found at (%d, %d)" % firstLinePos) - return firstLinePos + if x not in ret : + ret.append(x) + + ret.sort() + return ret def findStaffLines(imageFile, lineLength): """ @@ -316,45 +314,6 @@ def findStaffLines(imageFile, lineLength): x, ys = findStaffLinesInImage(image, lineLength) return ys -def findStaffLinesInImage(image, lineLength): - """ - Takes a image and returns co-ordinates of staff lines in pixels. - - Params: - - image: image object containing staff lines - - lineLength: required length of line for acceptance as staff line - - Returns a tuple of the following items: - - x: x co-ordinate of left end of staff lines - - ys: list of y co-ordinates of staff lines - """ - firstLineX, firstLineY = findTopStaffLine(image, lineLength) - # move 3 pixels to the right, to avoid line of pixels connectings - # all staffs together - firstLineX += 3 - - lines = [] - newLine = True - - width, height = image.size - - for y in xrange(firstLineY, height): - # if color of that pixel isn't white - if image.getpixel((firstLineX, y)) != (255,255,255): - # and it can be new staff line - if newLine: - # accept it - newLine = False - lines.append(y) - else: - # it's space between lines - newLine = True - - del image - - # return staff line indices - return firstLineX, lines - def generateTitleFrame(titleText, width, height, ttfFile): """ Generates frame with name of song and its author. @@ -650,6 +609,7 @@ def getNoteIndices(leftmostGrobsByMoment, currentLySrcFile = None + index = None while i < len(leftmostGrobsByMoment): if midiIndex == len(midiTicks): warn("Ran out of MIDI indices after %d. Current index: %d" % @@ -769,411 +729,6 @@ def getNoteIndices(leftmostGrobsByMoment, return alignedNoteIndices -class VideoFrameWriter(object): - """ - Generates frames for the final video, synchronized with audio. - Each frame is written to disk as a PNG file. - - Counts time between starts of two notes, gets their positions on - image and generates needed amount of frames. The index of the last - note is repeated, so that every index can be the left one in a pair. - The required number of frames for every pair is computed as a real - number and because a fractional number of frames can't be - generated, they are stored in dropFrame and if that is > 1, it - skips generating one frame. - """ - - def __init__(self, width, height, fps, cursorLineColor, - scrollNotes, leftMargin, rightMargin, - midiResolution, midiTicks, temposList): - """ - Params: - - width: pixel width of frames (and video) - - height: pixel height of frames (and video) - - fps: frame rate of video - - cursorLineColor: color of middle line - - scrollNotes: False selects cursor scrolling mode, - True selects note scrolling mode - - leftMargin: left margin for cursor when - cursor scrolling mode is enabled - - rightMargin: right margin for cursor when - cursor scrolling mode is enabled - - midiResolution: resolution of MIDI file - - midiTicks: list of ticks with NoteOnEvent - - temposList: list of possible tempos in MIDI - - leftMargin: width of left margin for cursor - - rightMargin: width of right margin for cursor - """ - self.midiIndex = 0 - self.tempoIndex = 0 - self.frameNum = 0 - - # Keep track of wall clock time to ensure that rounding errors - # when aligning indices to frames don't accumulate over time. - self.secs = 0.0 - - self.scrollNotes = scrollNotes - - # In cursor scrolling mode, this is the x-coordinate in the - # original image of the left edge of the frame (i.e. the - # left edge of the cropping rectangle). - self.leftEdge = None - - self.width = width - self.height = height - self.fps = fps - self.cursorLineColor = cursorLineColor - self.midiResolution = midiResolution - self.midiTicks = midiTicks - self.temposList = temposList - self.leftMargin = leftMargin - self.rightMargin = rightMargin - - def estimateFrames(self): - approxBeats = float(self.midiTicks[-1]) / self.midiResolution - debug("approx %.2f MIDI beats" % approxBeats) - beatsPerSec = 60.0 / self.tempo - approxDuration = approxBeats * beatsPerSec - debug("approx duration: %.2f seconds" % approxDuration) - estimatedFrames = approxDuration * self.fps - progress("SYNC: ly2video will generate approx. %d frames at %.3f frames/sec." % - (estimatedFrames, self.fps)) - - def write(self, indices, notesImage): - """ - Params: - - indices: indices of notes in pictures - - notesImage: filename of the image - """ - # folder to store frames for video - if not os.path.exists("notes"): - os.mkdir("notes") - - firstTempoTick, self.tempo = self.temposList[self.tempoIndex] - debug("first tempo is %.3f bpm" % self.tempo) - debug("final MIDI tick is %d" % self.midiTicks[-1]) - - notesPic = Image.open(notesImage) - cropTop, cropBottom = self.getCropTopAndBottom(notesPic) - - # duplicate last index - indices.append(indices[-1]) - - self.estimateFrames() - progress("Writing frames ...") - if not DEBUG: - progress("A dot is displayed for every 10 frames generated.") - - initialTick = self.midiTicks[self.midiIndex] - if initialTick > 0: - debug("\ncalculating wall-clock start for first audible MIDI event") - # This duration isn't used, but it's necessary to - # calculate it like this in order to ensure tempoIndex is - # correct before we start writing frames. - silentPreludeDuration = \ - self.secsElapsedForTempoChanges(0, initialTick, - 0, indices[0]) - - # generate all frames in between each pair of adjacent indices - for i in xrange(len(indices) - 1): - # get two indices of notes (pixels) - startIndex = indices[i] - endIndex = indices[i + 1] - indexTravel = endIndex - startIndex - - debug("\nwall-clock secs: %f" % self.secs) - debug("index: %d -> %d (indexTravel %d)" % - (startIndex, endIndex, indexTravel)) - - # get two indices of MIDI events (ticks) - startTick = self.midiTicks[self.midiIndex] - self.midiIndex += 1 - endTick = self.midiTicks[self.midiIndex] - ticks = endTick - startTick - debug("ticks: %d -> %d (%d)" % (startTick, endTick, ticks)) - - # If we have 1+ tempo changes in between adjacent indices, - # we need to keep track of how many seconds elapsed since - # the last one, since this will allow us to calculate how - # many frames we need in between the current pair of - # indices. - secsSinceIndex = \ - self.secsElapsedForTempoChanges(startTick, endTick, - startIndex, endIndex) - - # This is the exact time we are *aiming* for the frameset - # to finish at (i.e. the start time of the first frame - # generated after the writeVideoFrames() invocation below - # has written all the frames for the current frameset). - # However, since we have less than an infinite number of - # frames per second, there will typically be a rounding - # error and we'll miss our target by a small amount. - targetSecs = self.secs + secsSinceIndex - - debug(" secs at new index %d: %f" % - (endIndex, targetSecs)) - - # The ideal duration of the current frameset is the target - # end time minus the *actual* start time, not the ideal - # start time. This is crucially important to avoid - # rounding errors from accumulating over the course of the - # video. - neededFrameSetSecs = targetSecs - float(self.frameNum)/self.fps - debug(" need next frameset to last %f secs" % - neededFrameSetSecs) - - debug(" need %f frames @ %.3f fps" % - (neededFrameSetSecs * self.fps, self.fps)) - neededFrames = int(round(neededFrameSetSecs * self.fps)) - - if neededFrames > 0: - self.writeVideoFrames( - neededFrames, startIndex, indexTravel, - notesPic, cropTop, cropBottom) - - # Update time in the *ideal* (i.e. not real) world - this - # is totally independent of fps. - self.secs = targetSecs - - print - - progress("SYNC: Generated %d frames" % self.frameNum) - - def getCropTopAndBottom(self, image): - """ - Returns a tuple containing the y-coordinates of the top and - bottom edges of the cropping rectangle, relative to the given - (non-cropped) image. - """ - width, height = image.size - - topMarginSize, bottomMarginSize = self.getTopAndBottomMarginSizes(image) - bottomY = height - bottomMarginSize - progress(" Image height: %5d pixels" % height) - progress(" Top margin size: %5d pixels" % topMarginSize) - progress("Bottom margin size: %5d pixels (y=%d)" % - (bottomMarginSize, bottomY)) - - nonWhiteRows = height - topMarginSize - bottomMarginSize - progress("Visible content is formed of %d non-white rows of pixels" % - nonWhiteRows) - - # y-coordinate of centre of the visible content, relative to - # the original non-cropped image - nonWhiteCentre = topMarginSize + int(round(nonWhiteRows/2)) - progress("Centre of visible content is %d pixels from top" % - nonWhiteCentre) - - # Now choose top/bottom cropping coordinates which center - # the content in the video frame. - cropTop = nonWhiteCentre - int(round(self.height / 2)) - cropBottom = cropTop + self.height - - # Figure out the maximum height allowed which keeps the - # cropping rectangle within the source image. - maxTopHalf = topMarginSize + nonWhiteRows / 2 - maxBottomHalf = bottomMarginSize + nonWhiteRows / 2 - maxHeight = min(maxTopHalf, maxBottomHalf) * 2 - - if cropTop < 0: - fatal("Would have to crop %d pixels above top of image! " - "Try increasing the resolution DPI " - "(which would increase the size of the PNG to be cropped), " - "or reducing the video height to at most %d" % - (-cropTop, maxHeight)) - cropTop = 0 - - if cropBottom > height: - fatal("Would have to crop %d pixels below bottom of image! " - "Try increasing the resolution DPI " - "(which would increase the size of the PNG to be cropped), " - "or reducing the video height to at most %d" % - (cropBottom - height, maxHeight)) - cropBottom = height - - if cropTop > topMarginSize: - fatal("Would have to crop %d pixels below top of visible content! " - "Try increasing the video height to at least %d, " - "or decreasing the resolution DPI." - % (cropTop - topMarginSize, nonWhiteRows)) - cropTop = topMarginSize - - if cropBottom < bottomY: - fatal("Would have to crop %d pixels above bottom of visible content! " - "Try increasing the video height to at least %d, " - "or decreasing the resolution DPI." - % (bottomY - cropBottom, nonWhiteRows)) - cropBottom = bottomY - - progress("Will crop from y=%d to y=%d" % (cropTop, cropBottom)) - - return cropTop, cropBottom - - def getTopAndBottomMarginSizes(self, image): - """ - Counts the number of white-only rows of pixels at the top and - bottom of the given image. - """ - - width, height = image.size - - # This is way faster than width*height invocations of getPixel() - pixels = image.load() - - progress("Auto-detecting top margin; this may take a while ...") - topMargin = 0 - for y in xrange(height): - if self.isLineBlank(pixels, width, y): - topMargin += 1 - if topMargin % 10 == 0: - sys.stdout.write(".") - sys.stdout.flush() - else: - break - if topMargin >= 10: - print - - progress("Auto-detecting bottom margin; this may take a while ...") - bottomMargin = 0 - for y in xrange(height - 1, -1, -1): - if self.isLineBlank(pixels, width, y): - bottomMargin += 1 - if bottomMargin % 10 == 0: - sys.stdout.write(".") - sys.stdout.flush() - else: - break - if bottomMargin >= 10: - print - - bottomY = height - bottomMargin - if topMargin >= bottomY: - bug("Image was entirely white!\n" - "Top margin %d, bottom margin %d (y=%d), height %d" % - (topMargin, bottomMargin, bottomY, height)) - - return topMargin, bottomMargin - - def isLineBlank(self, pixels, width, y): - """ - Returns True iff the line with the given y coordinate - is entirely white. - """ - for x in xrange(width): - if pixels[x, y] != (255, 255, 255): - return False - return True - - def secsElapsedForTempoChanges(self, startTick, endTick, - startIndex, endIndex): - """ - Returns the time elapsed in between startTick and endTick, - where the only MIDI events in between (if any) are tempo - change events. - """ - secsSinceStartIndex = 0.0 - lastTick = startTick - while self.tempoIndex < len(self.temposList): - tempoTick, tempo = self.temposList[self.tempoIndex] - debug(" checking tempo #%d @ tick %d: %.3f bpm" % - (self.tempoIndex, tempoTick, tempo)) - if tempoTick >= endTick: - break - - self.tempoIndex += 1 - self.tempo = tempo - - if tempoTick == startTick: - continue - - # startTick < tempoTick < endTick - secsSinceStartIndex += self.ticksToSecs(lastTick, tempoTick) - debug(" last %d tempo %d" % (lastTick, tempoTick)) - debug(" secs since index %d: %f" % - (startIndex, secsSinceStartIndex)) - lastTick = tempoTick - - # Add on the time elapsed between the final tempo change - # and endTick: - secsSinceStartIndex += self.ticksToSecs(lastTick, endTick) - - debug(" secs between indices %d and %d: %f" % - (startIndex, endIndex, secsSinceStartIndex)) - return secsSinceStartIndex - - def writeVideoFrames(self, neededFrames, startIndex, indexTravel, - notesPic, cropTop, cropBottom): - """ - Writes the required number of frames to travel indexTravel - pixels from startIndex, incrementing frameNum for each frame - written. - """ - travelPerFrame = float(indexTravel) / neededFrames - debug(" travel per frame: %f pixels" % travelPerFrame) - debug(" generating %d frames: %d -> %d" % - (neededFrames, self.frameNum, self.frameNum + neededFrames - 1)) - - for i in xrange(neededFrames): - index = startIndex + int(round(i * travelPerFrame)) - debug(" writing frame %d index %d" % - (self.frameNum, index)) - - frame, cursorX = self.cropFrame(notesPic, index, - cropTop, cropBottom) - self.writeCursorLine(frame, cursorX) - - # Save the frame. ffmpeg doesn't work if the numbers in these - # filenames are zero-padded. - frame.save(tmpPath("notes", "frame%d.png" % self.frameNum)) - self.frameNum += 1 - if not DEBUG and self.frameNum % 10 == 0: - sys.stdout.write(".") - sys.stdout.flush() - - def cropFrame(self, notesPic, index, top, bottom): - if self.scrollNotes: - # Get frame from image of staff - centre = self.width / 2 - left = int(index - centre) - right = int(index + centre) - frame = notesPic.copy().crop((left, top, right, bottom)) - cursorX = centre - else: - if self.leftEdge is None: - # first frame - staffX, staffYs = findStaffLinesInImage(notesPic, 50) - self.leftEdge = staffX - self.leftMargin - - cursorX = index - self.leftEdge - debug(" left edge at %d, cursor at %d" % - (self.leftEdge, cursorX)) - if cursorX > self.width - self.rightMargin: - self.leftEdge = index - self.leftMargin - cursorX = index - self.leftEdge - debug(" <<< left edge at %d, cursor at %d" % - (self.leftEdge, cursorX)) - - rightEdge = self.leftEdge + self.width - frame = notesPic.copy().crop((self.leftEdge, top, - rightEdge, bottom)) - return frame, cursorX - - def writeCursorLine(self, frame, x): - for pixel in xrange(self.height): - frame.putpixel((x , pixel), self.cursorLineColor) - frame.putpixel((x + 1, pixel), self.cursorLineColor) - - def ticksToSecs(self, startTick, endTick): - beatsSinceTick = float(endTick - startTick) / self.midiResolution - debug(" beats from tick %d -> %d: %f (%d ticks per beat)" % - (startTick, endTick, beatsSinceTick, self.midiResolution)) - - secsSinceTick = beatsSinceTick * 60.0 / self.tempo - debug(" secs from tick %d -> %d: %f (%.3f bpm)" % - (startTick, endTick, secsSinceTick, self.tempo)) - - return secsSinceTick - def genWavFile(timidity, midiPath): """ Call TiMidity++ to convert MIDI to .wav. @@ -1238,131 +793,116 @@ def generateSilence(name, length): fSilence.close() return out -def output_divider_line(): - progress(60 * "-") - -def debug(text): - if DEBUG: - print text - -def progress(text): - print text - -def stderr(text): - sys.stderr.write(text + "\n") - -def warn(text): - stderr("WARNING: " + text) - -def fatal(text, status=1): - output_divider_line() - stderr("ERROR: " + text) - sys.exit(status) - -def bug(text, *issues): - if len(issues) == 0: - msg = """ -Sorry, ly2video has encountered a fatal bug as described above, -which it could not attribute to any known cause :-( - -Please consider searching: - """ - else: - msg = """ -Sorry, ly2video has encountered a fatal bug as described above :-( -It might be due to the following known issue(s): - -""" - for issue in issues: - msg += " https://github.com/aspiers/ly2video/issues/%d\n" % issue - - msg += """ -If you suspect this is not the case, please visit: -""" - - msg += """ - https://github.com/aspiers/ly2video/issues +def parseOptions(): + parser = ArgumentParser(prog=sys.argv[0]) -and if the problem is not listed there, please file a new -entry so we can get it fixed. Thanks! + group_inout = parser.add_argument_group(title='Input/output files') -Aborted execution.\ -""" - fatal(text + "\n" + msg) + group_inout.add_argument("-i", "--input", required=True, + help="input LilyPond file", metavar="INPUT-FILE") -def tmpPath(*dirs): - segments = [ 'ly2video.tmp' ] - segments.extend(dirs) - return os.path.join(runDir, *segments) + group_inout.add_argument("-b", "--beatmap", + help='name of beatmap file for adjusting MIDI tempo', + metavar="BEATMAP-FILE") -def parseOptions(): - parser = OptionParser("usage: %prog [options]") + group_inout.add_argument("--slide-show", dest="slideShow", + help="input file prefix to generate a slide show (see doc/slideshow.txt)", + metavar="SLIDESHOW-PREFIX") - parser.add_option("-i", "--input", dest="input", - help="input LilyPond file", metavar="INPUT-FILE") - parser.add_option("-o", "--output", dest="output", + group_inout.add_argument("-o", "--output", help='name of output video (e.g. "myNotes.avi") ' '[INPUT-FILE.avi]', metavar="OUTPUT-FILE") - parser.add_option("-b", "--beatmap", dest="beatmap", - help='name of beatmap file for adjusting MIDI tempo', - metavar="FILE") - parser.add_option("-c", "--color", dest="color", - help='name of color of middle bar [red]', - metavar="COLOR", default="red") - parser.add_option("-f", "--fps", dest="fps", - help='frame rate of final video [30]', - type="float", metavar="FPS", default=30.0) - parser.add_option("-q", "--quality", dest="quality", - help="video encoding quality as used by ffmpeg's -q option " - '(1 is best, 31 is worst) [10]', - type="int", metavar="N", default=10) - parser.add_option("-r", "--resolution", dest="dpi", - help='resolution in DPI [110]', - metavar="DPI", type="int", default=110) - parser.add_option("-x", "--width", dest="width", - help='pixel width of final video [1280]', - metavar="WIDTH", type="int", default=1280) - parser.add_option("-y", "--height", dest="height", - help='pixel height of final video [720]', - metavar="HEIGHT", type="int", default=720) - parser.add_option("-m", "--cursor-margins", dest="cursorMargins", + + group_scroll = parser.add_argument_group(title='Scrolling') + + group_scroll.add_argument("-m", "--cursor-margins", dest="cursorMargins", help='width of left/right margins for scrolling ' - 'in pixels [50,100]', - metavar="WIDTH,WIDTH", type="string", default='50,100') - parser.add_option("-p", "--padding", dest="padding", - help='time to pause on initial and final frames [1,1]', - metavar="SECS,SECS", type="string", default='1,1') - parser.add_option("-s", "--scroll-notes", dest="scrollNotes", + 'in pixels [%(default)s]', + metavar="WIDTH,WIDTH", default='50,100') + + group_scroll.add_argument("-s", "--scroll-notes", dest="scrollNotes", help='rather than scrolling the cursor from left to right, ' 'scroll the notation from right to left and keep the ' 'cursor in the centre', action="store_true", default=False) - parser.add_option("-t", "--title-at-start", dest="titleAtStart", + + group_video = parser.add_argument_group(title='Video output') + + group_video.add_argument("-f", "--fps", dest="fps", + help='frame rate of final video [%(default)s]', + type=float, metavar="FPS", default=30.0) + group_video.add_argument("-q", "--quality", + help="video encoding quality as used by ffmpeg's -q option " + '(1 is best, 31 is worst) [%(default)s]', + type=int, metavar="N", default=10) + group_video.add_argument("-r", "--resolution",dest="dpi", + help='resolution in DPI [%(default)s]', + metavar="DPI", type=int, default=110) + group_video.add_argument("--videoDef", + help='Definition of final video [1280x720]', + default=None) + group_video.add_argument("-x", "--width", + help='pixel width of final video [%(default)s]', + metavar="WIDTH", type=int, default=1280) + group_video.add_argument("-y", "--height", + help='pixel height of final video [%(default)s]', + metavar="HEIGHT", type=int, default=720) + + group_cursors = parser.add_argument_group(title='Cursors') + + group_cursors.add_argument("-c", "--color", + help='name of the cursor color [%(default)s]', + metavar="COLOR", default="red") + group_cursors.add_argument("--no-cursor", dest="noteCursor", + help='do not generate a cursor', + action="store_false", default=True) + group_cursors.add_argument("--note-cursor", dest="noteCursor", + help='generate a cursor following the score note by note (default)', + action="store_true", default=True) + group_cursors.add_argument("--measure-cursor", dest="measureCursor", + help='generate a cursor following the score measure by measure', + action="store_true", default=False) + group_cursors.add_argument("--slide-show-cursor", dest="slideShowCursor", type=float, + help="start and end positions on the cursor in the slide show", nargs=2, metavar=("START", "END")) + + group_startend = parser.add_argument_group(title='Start and end of the video') + + group_startend.add_argument("-t", "--title-at-start", dest="titleAtStart", help='adds title screen at the start of video ' '(with name of song and its author)', action="store_true", default=False) - parser.add_option("--title-duration", dest="titleDuration", - help='time to display the title screen [3]', - type="int", metavar="SECONDS", default=3) - parser.add_option("--ttf", "--title-ttf", dest="titleTtfFile", + group_startend.add_argument("--title-duration", dest="titleDuration", + help='time to display the title screen [%(default)s]', + type=int, metavar="SECONDS", default=3) + group_startend.add_argument("--ttf", "--title-ttf", dest="titleTtfFile", help='path to TTF font file to use in title', - type="string", metavar="FONT-FILE") - parser.add_option("--windows-ffmpeg", dest="winFfmpeg", + metavar="FONT-FILE") + + group_startend.add_argument("-p", "--padding", + help='time to pause on initial and final frames [%(default)s]', + metavar="SECS,SECS", default='1,1') + + group_os = parser.add_argument_group(title='External programs') + + group_os.add_argument("--windows-ffmpeg", dest="winFfmpeg", help='(for Windows users) folder with ffpeg.exe ' '(e.g. "C:\\ffmpeg\\bin\\")', metavar="PATH", default="") - parser.add_option("--windows-timidity", dest="winTimidity", + group_os.add_argument("--windows-timidity", dest="winTimidity", help='(for Windows users) folder with ' 'timidity.exe (e.g. "C:\\timidity\\")', metavar="PATH", default="") - parser.add_option("-d", "--debug", dest="debug", + + group_debug = parser.add_argument_group(title='Debug') + + group_debug.add_argument("-d", "--debug", help="enable debugging mode", action="store_true", default=False) - parser.add_option("-k", "--keep", dest="keepTempFiles", + group_debug.add_argument("-k", "--keep", dest="keepTempFiles", help="don't remove temporary working files", action="store_true", default=False) - parser.add_option("-v", "--version", dest="showVersion", + group_debug.add_argument("-v", "--version", dest="showVersion", help="show program version", action="store_true", default=False) @@ -1370,7 +910,7 @@ def parseOptions(): parser.print_help() sys.exit(0) - options, args = parser.parse_args() + options = parser.parse_args() if options.showVersion: showVersion() @@ -1379,10 +919,9 @@ def parseOptions(): fatal("Must specify --title-ttf=FONT-FILE with --title-at-start.") if options.debug: - global DEBUG - DEBUG = True + setDebug() - return options, args + return options def getVersion(): try: @@ -1401,7 +940,7 @@ def getVersion(): def showVersion(): print """ly2video %s -Copyright (C) 2012 Jiri "FireTight" Szabo, Adam Spiers +Copyright (C) 2012-2014 Jiri "FireTight" Szabo, Adam Spiers, Emmanuel Leguy License GPLv3+: GNU GPL version 3 or later . This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law.""" % getVersion() @@ -1720,11 +1259,30 @@ def writeSpaceTimeDumper(): (+ 0.0 (ly:moment-main time) (* (ly:moment-grace time) (/ 9 40))) file line char)))) +#(define (dump-spacetime-info-barline grob) + (let* ((extent (ly:grob-extent grob grob X)) + (system (ly:grob-system grob)) + (x-extent (ly:grob-extent grob system X)) + (left (car x-extent)) + (right (cdr x-extent)) + (paper-column (grob-get-paper-column grob)) + (time (ly:grob-property paper-column 'when 0)) + (cause (ly:grob-property grob 'cause))) + (if (not (equal? (ly:grob-property grob 'transparent) #t)) + (format #t "\\nly2videoBar: (~23,16f, ~23,16f) @ ~23,16f" + left right + (+ 0.0 (ly:moment-main time) (* (ly:moment-grace time) (/ 9 40))) + )))) + \layout { \context { \Voice \override NoteHead #'after-line-breaking = #dump-spacetime-info } + \context { + \Staff + \override BarLine #'after-line-breaking = #dump-spacetime-info-barline + } \context { \ChordNames \override ChordName #'after-line-breaking = #dump-spacetime-info @@ -1858,7 +1416,7 @@ def main(): - create a video file from the individual frames """ - (options, args) = parseOptions() + options = parseOptions() lilypondVersion, ffmpeg, timidity = findExecutableDependencies(options) @@ -1866,7 +1424,8 @@ def main(): # we'll have somewhere nice to save state. global runDir runDir = os.getcwd() - + setRunDir (runDir) + # Delete old temporary files. if os.path.isdir(tmpPath()): shutil.rmtree(tmpPath()) @@ -1896,6 +1455,10 @@ def main(): leftmostGrobsByMoment = getLeftmostGrobsByMoment(output, options.dpi, leftPaperMargin) + measuresXpositions = None + if options.measureCursor : + measuresXpositions = getMeasuresIndices(output, options.dpi, leftPaperMargin) + notesImage = tmpPath("sanitised.png") midiPath = tmpPath("sanitised.midi") @@ -1929,12 +1492,13 @@ def main(): fps = options.fps # generate notes + frameWriter = VideoFrameWriter(fps, getCursorLineColor(options), midiResolution, midiTicks, temposList) leftMargin, rightMargin = options.cursorMargins.split(",") - frameWriter = VideoFrameWriter( - options.width, options.height, fps, getCursorLineColor(options), - options.scrollNotes, int(leftMargin), int(rightMargin), - midiResolution, midiTicks, temposList) - frameWriter.write(noteIndices, notesImage) + frameWriter.scoreImage = ScoreImage(options.width, options.height, Image.open(notesImage), noteIndices, measuresXpositions, int(leftMargin), int(rightMargin), options.scrollNotes, options.noteCursor) + if options.slideShow : + lastOffset = midiTicks[-1]/midiResolution + frameWriter.push(SlideShow(options.slideShow,options.slideShowCursor,lastOffset)) + frameWriter.write() output_divider_line() wavPath = genWavFile(timidity, midiPath) diff --git a/synchro.py b/synchro.py new file mode 100644 index 0000000..b31d4b9 --- /dev/null +++ b/synchro.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python +# coding=utf-8 + +# ly2video - generate performances video from LilyPond source files +# Copyright (C) 2012 Jiri "FireTight" Szabo +# Copyright (C) 2012 Adam Spiers +# Copyright (C) 2014 Emmanuel Leguy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# For more information about this program, please visit +# . + +from utils import * + +class TimeCode (Observable): + + """ + The sychro.Timecode class handles the synchronisation data: atEnd(), + gotToNextNote(), nbFramesToNextNote() methods. + The 'Observer' design pattern to produce frames. The timecode object is a + kind of 'conductor' of the process + """ + + def __init__(self, miditicks, temposList, midiResolution, fps): + """ + Params: + - midiTicks: list of ticks with NoteOnEvent + - temposList: list of possible tempos in MIDI + - midiResolution: resolution of MIDI file + - fps: frame per sec. Needed to get the + needed frames between 2 notes + """ + Observable.__init__(self) + self.fps = fps + self.__miditicks = miditicks + self.__currentTickIndex = 0 + self.tempoIndex = 0 + self.temposList = temposList + self.midiResolution = midiResolution + + # Keep track of wall clock time to ensure that rounding errors + # when aligning indices to frames don't accumulate over time. + self.secs = 0.0 + + self.__wroteFrames = 0 + + firstTempoTick, self.tempo = self.temposList[self.tempoIndex] + debug("first tempo is %.3f bpm" % self.tempo) + debug("final MIDI tick is %d" % self.__miditicks[-1]) + + self.estimateFrames() + progress("Writing frames ...") + if not DEBUG: + progress("A dot is displayed for every 10 frames generated.") + + initialTick = self.__miditicks[self.__currentTickIndex] + if initialTick > 0: + #self.__miditicks = [0] + self.__miditicks + debug("\ncalculating wall-clock start for first audible MIDI event") + # This duration isn't used, but it's necessary to + # calculate it like this in order to ensure tempoIndex is + # correct before we start writing frames. + silentPreludeDuration = \ + self.secsElapsedForTempoChanges(0, initialTick) + + self.__currentTick = self.__miditicks[self.__currentTickIndex] + self.__nextTick = self.__miditicks[self.__currentTickIndex + 1] + self.currentOffset = float(self.__currentTick)/self.midiResolution + self.nextOffset = float(self.__nextTick)/self.midiResolution + + + def atEnd(self): + return self.__currentTickIndex + 2 >= len(self.__miditicks) + + def goToNextNote (self): + self.__currentTickIndex += 1 + self.__currentTick = self.__miditicks[self.__currentTickIndex] + self.__nextTick = self.__miditicks[self.__currentTickIndex+1] + self.currentOffset = float(self.__currentTick)/self.midiResolution + self.nextOffset = float(self.__nextTick)/self.midiResolution + ticks = self.__nextTick - self.__currentTick + debug("ticks: %d -> %d (%d)" % (self.__currentTick, self.__nextTick, ticks)) + + self.notifyObservers() + + def nbFramesToNextNote(self): + # If we have 1+ tempo changes in between adjacent indices, + # we need to keep track of how many seconds elapsed since + # the last one, since this will allow us to calculate how + # many frames we need in between the current pair of + # indices. + secsSinceIndex = self.secsElapsedForTempoChanges(self.__currentTick, self.__nextTick) + + # This is the exact time we are *aiming* for the frameset + # to finish at (i.e. the start time of the first frame + # generated after the writeVideoFrames() invocation below + # has written all the frames for the current frameset). + # However, since we have less than an infinite number of + # frames per second, there will typically be a rounding + # error and we'll miss our target by a small amount. + targetSecs = self.secs + secsSinceIndex + debug(" secs at new tick %d: %f" % (self.__nextTick, targetSecs)) + + # The ideal duration of the current frameset is the target + # end time minus the *actual* start time, not the ideal + # start time. This is crucially important to avoid + # rounding errors from accumulating over the course of the + # video. + neededFrameSetSecs = targetSecs - float(self.__wroteFrames)/self.fps + debug(" need next frameset to last %f secs" % neededFrameSetSecs) + + debug(" need %f frames @ %.3f fps" % (neededFrameSetSecs * self.fps, self.fps)) + neededFrames = int(round(neededFrameSetSecs * self.fps)) + self.__wroteFrames += neededFrames + # Update time in the *ideal* (i.e. not real) world - this + # is totally independent of fps. + self.secs = targetSecs + + return neededFrames + + def secsElapsedForTempoChanges(self, startTick, endTick): + """ + Returns the time elapsed in between startTick and endTick, + where the only MIDI events in between (if any) are tempo + change events. + """ + secsSinceStartIndex = 0.0 + lastTick = startTick + while self.tempoIndex < len(self.temposList): + tempoTick, tempo = self.temposList[self.tempoIndex] + debug(" checking tempo #%d @ tick %d: %.3f bpm" % + (self.tempoIndex, tempoTick, tempo)) + if tempoTick >= endTick: + break + + self.tempoIndex += 1 + self.tempo = tempo + + if tempoTick == startTick: + continue + + # startTick < tempoTick < endTick + secsSinceStartIndex += self.ticksToSecs(lastTick, tempoTick) + debug(" last %d tempo %d" % (lastTick, tempoTick)) + debug(" secs : %f" % + (secsSinceStartIndex)) + lastTick = tempoTick + + # Add on the time elapsed between the final tempo change + # and endTick: + secsSinceStartIndex += self.ticksToSecs(lastTick, endTick) + +# debug(" secs between indices %d and %d: %f" % +# (startIndex, endIndex, secsSinceStartIndex)) + return secsSinceStartIndex + + def ticksToSecs(self, startTick, endTick): + beatsSinceTick = float(endTick - startTick) / self.midiResolution + debug(" beats from tick %d -> %d: %f (%d ticks per beat)" % + (startTick, endTick, beatsSinceTick, self.midiResolution)) + + secsSinceTick = beatsSinceTick * 60.0 / self.tempo + debug(" secs from tick %d -> %d: %f (%.3f bpm)" % + (startTick, endTick, secsSinceTick, self.tempo)) + + return secsSinceTick + + def estimateFrames(self): + approxBeats = float(self.__miditicks[-1]) / self.midiResolution + debug("approx %.2f MIDI beats" % approxBeats) + beatsPerSec = 60.0 / self.tempo + approxDuration = approxBeats * beatsPerSec + debug("approx duration: %.2f seconds" % approxDuration) + estimatedFrames = approxDuration * self.fps + progress("SYNC: ly2video will generate approx. %d frames at %.3f frames/sec." % + (estimatedFrames, self.fps)) + + + + \ No newline at end of file diff --git a/test.py b/test.py new file mode 100644 index 0000000..465e73c --- /dev/null +++ b/test.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python +# coding=utf-8 + +# ly2video - generate performances video from LilyPond source files +# Copyright (C) 2012 Jiri "FireTight" Szabo +# Copyright (C) 2012 Adam Spiers +# Copyright (C) 2014 Emmanuel Leguy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# For more information about this program, please visit +# . + +import unittest +from video import * +from synchro import * +from PIL import Image + +class TimeCodeTest (unittest.TestCase): + + def setUp(self): + self.timecode = TimeCode([0,384,768,1152],[(0,60.0)], 384, 30.0) + + def testTicksToSecs_withNullInterval (self): + for tempo in range (1,300): + self.timecode.tempo = float(tempo) + secsSinceStartIndex = self.timecode.ticksToSecs(0, 0) + self.assertEqual(secsSinceStartIndex, 0.0, "") + + def testTicksToSecs_withOneSecInterval (self): + secsSinceStartIndex = self.timecode.ticksToSecs(0, 384) + self.assertEqual(secsSinceStartIndex, 1.0, "") + + def testTicksToSecs_withTwoSecInterval (self): + secsSinceStartIndex = self.timecode.ticksToSecs(0, 768) + self.assertEqual(secsSinceStartIndex, 2.0, "") + + def testTicksToSecs_withOtherTempo (self): + self.timecode.tempo = 90.0 + secsSinceStartIndex = self.timecode.ticksToSecs(0, 1152) + self.assertEqual(secsSinceStartIndex, 2.0, "") + + def testSecsElapsedForTempoChanges (self): + self.timecode.temposList = [(0, 60.0),(1152, 90.0),(3456, 60.0)] + result = self.timecode.secsElapsedForTempoChanges(startTick = 0, endTick = 1152) + self.assertEqual(result,3.0,"") + result = self.timecode.secsElapsedForTempoChanges(startTick = 0, endTick = 3456) + self.assertEqual(result,6.0,"") + result = self.timecode.secsElapsedForTempoChanges(startTick = 1152, endTick = 3456) + self.assertEqual(result,4.0,"") + result = self.timecode.secsElapsedForTempoChanges(startTick = 3456, endTick = 4608) + self.assertEqual(result,3.0,"") + result = self.timecode.secsElapsedForTempoChanges(startTick = 0, endTick = 4608) + self.assertEqual(result,12.0,"") + + def testNbFramesToNextNote(self): + self.assertEqual(self.timecode.nbFramesToNextNote(), 30, "") + + def testNbFramesToNextNote_withApprox_plus(self): + self.timecode = TimeCode([0,380,768,1152],[(0,60.0)], 384, 30.0) + self.assertEqual(self.timecode.nbFramesToNextNote(), 30, "") + + def testNbFramesToNextNote_withApprox_minus(self): + self.timecode = TimeCode([0,390,768,1152],[(0,60.0)], 384, 30.0) + self.assertEqual(self.timecode.nbFramesToNextNote(), 30, "") + + def testNbFramesToNextNote_withApprox_correction(self): + self.timecode = TimeCode([0,370,768,1152],[(0,60.0)], 384, 30.0) + self.assertEqual(self.timecode.nbFramesToNextNote(), 29, "") + self.timecode.goToNextNote() + self.assertEqual(self.timecode.nbFramesToNextNote(), 31, "") + + def testNbFramesToNextNote_withTempoChange(self): + self.timecode = TimeCode([0,384,768,1152],[(0,60.0),(384,90.0),(768,60.0)], 384, 30.0) + self.assertEqual(self.timecode.nbFramesToNextNote(), 30, "") + self.timecode.goToNextNote() + self.assertEqual(self.timecode.nbFramesToNextNote(), 20, "") + self.timecode.goToNextNote() + self.assertEqual(self.timecode.nbFramesToNextNote(), 30, "") + + def testNbFramesToNextNote_withTempoChange_withApproxCorrection(self): + self.timecode = TimeCode([0,370,768,1152],[(0,60.0),(370,90.0),(768,60.0)], 384, 30.0) + self.assertEqual(self.timecode.nbFramesToNextNote(), 29, "") + self.timecode.goToNextNote() + self.assertEqual(self.timecode.nbFramesToNextNote(), 21, "") + self.timecode.goToNextNote() + self.assertEqual(self.timecode.nbFramesToNextNote(), 30, "") + + def testGotToNextNote (self): + self.assertEqual(self.timecode.currentOffset, 0, "") + self.assertEqual(self.timecode.nextOffset, 1, "") + self.timecode.goToNextNote() + self.assertEqual(self.timecode.currentOffset, 1, "") + self.assertEqual(self.timecode.nextOffset, 2, "") + + def testGotToNextNote_withScoreImageNotification (self): + image = ScoreImage(16,16,Image.new("RGB",(16,16),(0,0,0)), [0,1,2,3], []) + self.timecode.registerObserver(image) + self.timecode.goToNextNote() + self.assertEqual(image.currentXposition,1,"") + + def testGotToNextNote_withSlideShowNotification (self): + image = Image.new("RGB",(16,16),(0,0,0)) + image.save("test0000.0000.png") + slideshow = SlideShow("test") + self.timecode.registerObserver(slideshow) + self.timecode.goToNextNote() + self.assertEqual(slideshow.startOffset,1.0,"") + os.remove("test0000.0000.png") + + def testCompleteFollowing (self): + scoreimage = ScoreImage(16,16,Image.new("RGB",(16,16),(0,0,0)), [0,1,2,3], []) + image = Image.new("RGB",(16,16),(0,0,0)) + image.save("test0000.0000.png") + slideshow = SlideShow("test") + self.timecode.registerObserver(scoreimage) + self.timecode.registerObserver(slideshow) + memPos = scoreimage.currentXposition + memOffset = slideshow.startOffset + while not self.timecode.atEnd(): + self.timecode.goToNextNote() + # has the scoreImage position changed? + self.assertNotEqual(scoreimage.currentXposition, memPos, "") + memPos = scoreimage.currentXposition + # has the slideshow current offset changed? + self.assertNotEqual(slideshow.startOffset, memOffset, "") + memOffset = slideshow.startOffset + os.remove("test0000.0000.png") + + def testNotAtEnd(self): + self.assertFalse(self.timecode.atEnd(), "") + + def testAtEnd(self): + self.timecode.goToNextNote() + self.timecode.goToNextNote() + self.assertTrue(self.timecode.atEnd(), "") + +class ScoreImageTest (unittest.TestCase): + + def setUp(self): + image = Image.new("RGB",(1000,200),(255,255,255)) + self.blankImage = ScoreImage(1000,200,image, [], []) + image = Image.new("RGB",(1000,200),(255,255,255)) + image.putpixel((500,50),(0,0,0)) + image.putpixel((500,149),(0,0,0)) + self.pointsImage = ScoreImage(1000,200,image, [], []) + + # PRIVATE METHODS + # __isLineBlank + def test__IsLineBlank (self): + image = Image.new("RGB",(16,16),(255,255,255)) + for x in range(16) : image.putpixel((x,8),(0,0,0)) + pixels = image.load() + w, h = image.size + self.assertTrue(self.blankImage._ScoreImage__isLineBlank(pixels, w, 0), "Line should be blank") + self.assertFalse(self.blankImage._ScoreImage__isLineBlank(pixels, w, 8), "Line should not be blank") + + def test__IsLineBlank_withLineAlmostBlack (self): + image = Image.new("RGB",(16,16),(255,255,255)) + for x in range(15) : image.putpixel((x,10),(0,0,0)) + w, h = image.size + pixels = image.load() + self.assertFalse(self.blankImage._ScoreImage__isLineBlank(pixels, w, 10), "Line should not be blank") + + def test__IsLineBlank_withLineAlmostBlank (self): + image = Image.new("RGB",(16,16),(255,255,255)) + image.putpixel((4,4),(0,0,0)) + w, h = image.size + pixels = image.load() + self.assertFalse(self.blankImage._ScoreImage__isLineBlank(pixels, w, 4), "Line should not be blank") + + # __setCropTopAndBottom + def test__setCropTopAndBottom_withBlackImage(self): + blackImage = ScoreImage(16,16,Image.new("RGB",(16,16),(0,0,0)), [], []) + blackImage._ScoreImage__setCropTopAndBottom() + self.assertEqual(blackImage._ScoreImage__cropTop, 0, "Bad cropTop!") + self.assertEqual(blackImage._ScoreImage__cropBottom, 16, "Bad cropBottom!") + + def test__setCropTopAndBottom_withBlackImageTooSmall(self): + blackImage = ScoreImage(16,17,Image.new("RGB",(16,16),(0,0,0)), [], []) + with self.assertRaises(SystemExit) as cm: + blackImage._ScoreImage__setCropTopAndBottom() + self.assertEqual(cm.exception.code, 1) + + def test__setCropTopAndBottom_withBlackImageTooBig(self): + blackImage = ScoreImage(16,17,Image.new("RGB",(16,16),(0,0,0)), [], []) + with self.assertRaises(SystemExit) as cm: + blackImage._ScoreImage__setCropTopAndBottom() + self.assertEqual(cm.exception.code, 1) + + def test__setCropTopAndBottom_withBlackPoint(self): + image = Image.new("RGB",(16,16),(255,255,255)) + image.putpixel((8,8),(0,0,0)) + blackPointImage = ScoreImage(16,9,image, [], []) + #blackPointImage.areaHeight = 9 + blackPointImage._ScoreImage__setCropTopAndBottom() + self.assertEqual(blackPointImage._ScoreImage__cropTop, 4, "Bad cropTop!") + self.assertEqual(blackPointImage._ScoreImage__cropBottom, 13, "Bad cropBottom!") + + def test__setCropTopAndBottom_withNonCenteredContent(self): + image = Image.new("RGB",(30,30),(255,255,255)) + image.putpixel((8,4),(0,0,0)) + image.putpixel((8,12),(0,0,0)) + scoreImage = ScoreImage(16,20,Image.new("RGB",(16,16),(0,0,0)), [], []) + with self.assertRaises(SystemExit) as cm: + scoreImage._ScoreImage__setCropTopAndBottom() + self.assertEqual(cm.exception.code, 1) + + def test__setCropTopAndBottom_withVideoHeightTooSmall(self): + image = Image.new("RGB",(16,16),(255,255,255)) + image.putpixel((8,4),(0,0,0)) + image.putpixel((8,12),(0,0,0)) + scoreImage = ScoreImage(16,8,Image.new("RGB",(16,16),(0,0,0)), [], []) + with self.assertRaises(SystemExit) as cm: + scoreImage._ScoreImage__setCropTopAndBottom() + self.assertEqual(cm.exception.code, 1) + + # __cropFrame + def test__cropFrame(self): + image = Image.new("RGB",(1000,200),(255,255,255)) + ox=20 + for x in range(51) : image.putpixel((x+ox,20),(0,0,0)) + scoreImage = ScoreImage(200,40,image, [], []) + index = 70 + areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index) + w,h = areaFrame.size + self.assertEqual(w, 200, "") + self.assertEqual(h, 40, "") + self.assertEqual(cursorX, 97 , "") + + def test__cropFrame_withIndexHigherThanWidth (self): + image = Image.new("RGB",(1000,200),(255,255,255)) + ox=20 + for x in range(51) : image.putpixel((x+ox,20),(0,0,0)) + scoreImage = ScoreImage(200,40,image, [], []) + index = 200 + areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index) + w,h = areaFrame.size + self.assertEqual(w, 200, "") + self.assertEqual(h, 40, "") + self.assertEqual(cursorX, 50 , "") + + def test__cropFrame_changeCropping (self): + image = Image.new("RGB",(1000,200),(255,255,255)) + ox=20 + for x in range(51) : image.putpixel((x+ox,20),(0,0,0)) + scoreImage = ScoreImage(500,40,image, [], [], 50, 200) + areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index = 300) + # must be tequal to left margin + self.assertEqual(cursorX, 50 , "") + + def test__cropFrame_lastCropping (self): + image = Image.new("RGB",(1000,200),(255,255,255)) + ox=20 + for x in range(51) : image.putpixel((x+ox,20),(0,0,0)) + scoreImage = ScoreImage(500,40,image, [], [], 50, 200) +# # first picture cropping +# areaFrame = scoreImage._ScoreImage__cropFrame(index = 100) +# self.assertEqual(areaFrame.cursorX, 127 , "") +# areaFrame = scoreImage._ScoreImage__cropFrame(index = 200) +# self.assertEqual(areaFrame.cursorX, 227 , "") +# # second picture cropping +# areaFrame = scoreImage._ScoreImage__cropFrame(index = 300) +# self.assertEqual(areaFrame.cursorX, 50 , "") +# areaFrame = scoreImage._ScoreImage__cropFrame(index = 400) +# self.assertEqual(areaFrame.cursorX, 150 , "") +# areaFrame = scoreImage._ScoreImage__cropFrame(index = 500) +# self.assertEqual(areaFrame.cursorX, 250 , "") + # third (last) picture cropping + areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index = 600) + self.assertEqual(cursorX, 50 , "") + areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index = 700) + self.assertEqual(cursorX, 200 , "") + areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index = 800) + self.assertEqual(cursorX, 300 , "") + areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index = 900) + self.assertEqual(cursorX, 400 , "") + # Must overflow of the right margin for the last cropping. + self.assertGreater(cursorX, 300, "") + # No new cropping. For a new cropping the cursor position is equals to left margin. + self.assertNotEqual(cursorX, 50, "") + + def test__init__Xposition(self): + image = Image.new("RGB",(1000,200),(255,255,255)) + scoreImage = ScoreImage(500,40,image, [1,2,3], [], 50, 200) + self.assertEqual(scoreImage.currentXposition, 1, "") + + def testMoveToNextNote(self): + image = Image.new("RGB",(1000,200),(255,255,255)) + scoreImage = ScoreImage(500,40,image, [1,2,3], [], 50, 200) + scoreImage.moveToNextNote() + self.assertEqual(scoreImage.currentXposition, 2, "") + + def testTravelToNextNote (self): + image = Image.new("RGB",(1000,200),(255,255,255)) + scoreImage = ScoreImage(500,40,image, [10,20,30], [], 50, 200) + self.assertEqual(scoreImage.travelToNextNote, 10, "") + + + # PUBLIC METHODS + # topCroppable + def testTopCroppable_withBlackImage (self): + blackImage = ScoreImage(16,16,Image.new("RGB",(16,16),(0,0,0)), [], []) + self.assertEqual(blackImage.topCroppable, 0, "Bad topMarginSize") + + def testTopCroppable_withBlankImage (self): + def testTopCroppable(image): + return image.topCroppable + blankImage = ScoreImage(16,16,Image.new("RGB",(16,16),(255,255,255)), [], []) + self.assertRaises(BlankScoreImageError,testTopCroppable,blankImage) + + def testTopCroppable_withHorizontalBlackLine (self): + image = Image.new("RGB",(16,16),(255,255,255)) + for x in range(16) : image.putpixel((x,8),(0,0,0)) + blackImage = ScoreImage(16,16,image, [], []) + self.assertEqual(blackImage.topCroppable, 8, "Bad topMarginSize") + + def testTopCroppable_withBlackPoint (self): + image = Image.new("RGB",(16,16),(255,255,255)) + image.putpixel((8,8),(0,0,0)) + blackImage = ScoreImage(16,16,image, [], []) + self.assertEqual(blackImage.topCroppable, 8, "Bad topMarginSize") + + # bottomCroppable + def testBottomCroppable_withBlackImage (self): + blackImage = ScoreImage(16, 16, Image.new("RGB",(16,16),(0,0,0)), [], []) + self.assertEqual(blackImage.bottomCroppable, 0, "Bad bottomMarginSize") + + def testBottomCroppable_withBlankImage (self): + def testBottomCroppable(image): + return image.bottomCroppable + blankImage = ScoreImage(16, 16, Image.new("RGB",(16,16),(255,255,255)), [], []) + self.assertRaises(BlankScoreImageError,testBottomCroppable,blankImage) + + def testBottomCroppable_withHorizontalBlackLine (self): + image = Image.new("RGB",(16,16),(255,255,255)) + for x in range(16) : image.putpixel((x,8),(0,0,0)) + blackImage = ScoreImage(16, 16, image, [], []) + self.assertEqual(blackImage.bottomCroppable, 7, "Bad topMarginSize") + + def testBottomCroppable_withBlackPoint (self): + image = Image.new("RGB",(16,16),(255,255,255)) + image.putpixel((8,8),(0,0,0)) + blackImage = ScoreImage(16, 16, image, [], []) + self.assertEqual(blackImage.bottomCroppable, 7, "Bad topMarginSize") + + # makeFrame + def testMakeFrame (self): + image = Image.new("RGB",(1000,200),(255,255,255)) + ox=20 + for x in range(51) : image.putpixel((x+ox,20),(0,0,0)) + scoreImage = ScoreImage(200, 40, image, [70, 100], []) + scoreImage.areaWidth = 200 + scoreImage.areaHeight = 40 + areaFrame = scoreImage.makeFrame(numFrame = 10, among = 30) + w,h = areaFrame.size + self.assertEqual(w, 200, "") + self.assertEqual(h, 40, "") + +class CursorsTest (unittest.TestCase): + + def testWriteCursorLine (self): + frame = Image.new("RGB",(16,16),(255,255,255)) + writeCursorLine(frame, 10, (255,0,0)) + for i in range (16): + self.assertEqual(frame.getpixel((10,i)), (255,0,0), "") + + def testWriteCursorLineOut (self): + frame = Image.new("RGB",(16,16),(255,255,255)) + self.assertRaises(Exception, writeCursorLine, frame, 20) + + def testWriteMeasureCursor (self): + frame = Image.new("RGB",(16,16),(255,255,255)) + writeMeasureCursor(frame, 5, 10, (255,0,0)) + for i in range (5): + self.assertEqual(frame.getpixel((5+i,14)), (255,0,0), "") + + def testWriteMeasureCursorOut (self): + frame = Image.new("RGB",(16,16),(255,255,255)) + self.assertRaises(Exception, writeMeasureCursor, frame, 20, 30, (255,0,0)) + +class VideoFrameWriterTest(unittest.TestCase): + + def setUp(self): + self.frameWriter = VideoFrameWriter( + fps = 30.0, + cursorLineColor = (255,0,0), + midiResolution = 384, + midiTicks = [0, 384, 768, 1152, 1536], + temposList = [(0, 60.0)] + ) + self.image = Image.new("RGB",(16,16),(255,255,255)) + for x in range(16) : self.image.putpixel((x,8),(0,0,0)) + + + def testPush (self): + frameWriter = VideoFrameWriter(30.0,(255,0,0),384.0,[0,384,768,1152],[(0,60.0)]) + frameWriter.scoreImage = Media(1000,200) + frameWriter.push(Media(100, 100)) + self.assertEqual(frameWriter.height, 300) + + def testScoreImageSetter (self): + frameWriter = VideoFrameWriter(30.0,(255,0,0),384.0,[0,384,768,1152],[(0,60.0)]) + frameWriter.scoreImage = ScoreImage(1000,200,Image.new("RGB",(1000,200),(255,255,255)), [], []) + self.assertEqual(frameWriter.width, 1000) + self.assertEqual(frameWriter.height, 200) + + def testFindStaffLinesInImage (self): + image = Image.new("RGB",(1000,200),(255,255,255)) + for x in range(51) : image.putpixel((x+20,20),(0,0,0)) + staffX, staffYs = findStaffLinesInImage(image, 50) + self.assertEqual(staffX, 23, "") + self.assertEqual(staffYs[0], 20, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..c3d3fb4 --- /dev/null +++ b/utils.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python +# coding=utf-8 + +# ly2video - generate performances video from LilyPond source files +# Copyright (C) 2012 Jiri "FireTight" Szabo +# Copyright (C) 2012 Adam Spiers +# Copyright (C) 2014 Emmanuel Leguy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# For more information about this program, please visit +# . + +import sys +import os + +DEBUG = False # --debug sets to True +RUNDIR = "" + +def setDebug(): + global DEBUG + DEBUG = True + +def debug(text): + if DEBUG: + print text + +def progress(text): + print text + +def stderr(text): + sys.stderr.write(text + "\n") + +def warn(text): + stderr("WARNING: " + text) + +def output_divider_line(): + progress(60 * "-") + +def fatal(text, status=1): + output_divider_line() + stderr("ERROR: " + text) + sys.exit(status) + +def bug(text, *issues): + if len(issues) == 0: + msg = """ +Sorry, ly2video has encountered a fatal bug as described above, +which it could not attribute to any known cause :-( + +Please consider searching: + """ + else: + msg = """ +Sorry, ly2video has encountered a fatal bug as described above :-( +It might be due to the following known issue(s): + +""" + for issue in issues: + msg += " https://github.com/aspiers/ly2video/issues/%d\n" % issue + + msg += """ +If you suspect this is not the case, please visit: +""" + + msg += """ + https://github.com/aspiers/ly2video/issues + +and if the problem is not listed there, please file a new +entry so we can get it fixed. Thanks! + +Aborted execution.\ +""" + fatal(text + "\n" + msg) + +def setRunDir (runDir): + global RUNDIR + RUNDIR = runDir + +def tmpPath(*dirs): + segments = [ 'ly2video.tmp' ] + segments.extend(dirs) + return os.path.join(RUNDIR, *segments) + + +class Observable: + + def __init__(self): + self.__observers = [] + + def registerObserver(self, observer): + self.__observers.append(observer) + + def notifyObservers (self): + for observer in self.__observers : + observer.update(self) + +class Observer: + + def update (self, observable): + pass diff --git a/video.py b/video.py new file mode 100644 index 0000000..abdb837 --- /dev/null +++ b/video.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python +# coding=utf-8 + +# ly2video - generate performances video from LilyPond source files +# Copyright (C) 2012 Jiri "FireTight" Szabo +# Copyright (C) 2012 Adam Spiers +# Copyright (C) 2014 Emmanuel Leguy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# For more information about this program, please visit +# . + +from synchro import * +from utils import * +import os +from PIL import Image + +# Image manipulation functions + +def writeCursorLine(image, X, color): + """Draws a line on the image""" + for pixel in xrange(image.size[1]): + image.putpixel((X , pixel), color) + image.putpixel((X + 1, pixel), color) + +def writeMeasureCursor(image, start, end, color, cursor_height=10): + """Draws a box at the bottom of the image""" + w, h = image.size + if start > w : + raise Exception() + for dx in range(end-start) : + for y in xrange(cursor_height): + if start + dx < w and start + dx > 0 : + image.putpixel((start + dx, h-y-1), color) + +def findTopStaffLine(image, lineLength): + """ + Returns the coordinates of the left-most pixel in the top line of + the first staff in the image. + + FIXME: The code assumes that the first staff is not indented + further right than subsequent staffs. + + Params: + - image: image with staff lines + - lineLength: needed length of line to accept it as staff line + """ + # position of the first line on image + firstLinePos = (-1, -1) + + width, height = image.size + + # Start searching at the hard left but allow for a left margin. + for x in xrange(width): + for y in xrange(height): + for length in xrange(lineLength): + # testing color of pixels in range (startPos, startPos + lineLength) + if image.getpixel((x + length, y)) == (255,255,255): + # if it's white then it's not a staff line + firstLinePos = (-1, -1) + break + else: + # else it can be + firstLinePos = (x, y) + # when have a valid position, break out + if firstLinePos != (-1, -1): + break + if firstLinePos != (-1, -1): + break + + progress("First staff line found at (%d, %d)" % firstLinePos) + return firstLinePos + +def findStaffLinesInImage(image, lineLength): + """ + Takes a image and returns co-ordinates of staff lines in pixels. + + Params: + - image: image object containing staff lines + - lineLength: required length of line for acceptance as staff line + + Returns a tuple of the following items: + - x: x co-ordinate of left end of staff lines + - ys: list of y co-ordinates of staff lines + """ + firstLineX, firstLineY = findTopStaffLine(image, lineLength) + # move 3 pixels to the right, to avoid line of pixels connectings + # all staffs together + firstLineX += 3 + + lines = [] + newLine = True + + width, height = image.size + + for y in xrange(firstLineY, height): + # if color of that pixel isn't white + if image.getpixel((firstLineX, y)) != (255,255,255): + # and it can be new staff line + if newLine: + # accept it + newLine = False + lines.append(y) + else: + # it's space between lines + newLine = True + + del image + + # return staff line indices + return firstLineX, lines + + +class VideoFrameWriter(object): + """ + Generates frames for the final video, synchronized with audio. + Each frame is written to disk as a PNG file. + + Counts time between starts of two notes, gets their positions on + image and generates needed amount of frames. The index of the last + note is repeated, so that every index can be the left one in a pair. + The required number of frames for every pair is computed as a real + number and because a fractional number of frames can't be + generated, they are stored in dropFrame and if that is > 1, it + skips generating one frame. + + The VideoFrameWriter can manage severals medias. So the push method + can be used to stack medias one above the other. + """ + + def __init__(self, fps, cursorLineColor, + midiResolution, midiTicks, temposList): + """ + Params: + - videoDef: Strict definition of the final video + - fps: frame rate of video + - cursorLineColor: color of middle line + - midiResolution: resolution of MIDI file + - midiTicks: list of ticks with NoteOnEvent + - temposList: list of possible tempos in MIDI + """ + self.frameNum = 0 + + # In cursor scrolling mode, this is the x-coordinate in the + # original image of the left edge of the frame (i.e. the + # left edge of the cropping rectangle). + self.leftEdge = None + + self.width = None + self.height = None + self.fps = fps + self.cursorLineColor = cursorLineColor + + self.runDir = None + + self.__scoreImage = None + self.__medias = [] + self.__timecode = TimeCode (midiTicks,temposList,midiResolution,fps) + + def push (self, media): + self.height += media.height + self.__medias.append(media) + self.__timecode.registerObserver(media) + + @property + def scoreImage (self): + return self.__scoreImage + + @scoreImage.setter + def scoreImage (self, scoreImage): + self.width = scoreImage.width + self.height = scoreImage.height + self.__scoreImage = scoreImage + self.__scoreImage.cursorLineColor = self.cursorLineColor + self.__timecode.registerObserver(scoreImage) + + def write (self): + # folder to store frames for video + if not os.path.exists("notes"): + os.mkdir("notes") + + while not self.__timecode.atEnd() : + neededFrames = self.__timecode.nbFramesToNextNote() + for i in xrange(neededFrames): + videoFrame = self.__makeFrame(i, neededFrames) + # Save the frame. ffmpeg doesn't work if the numbers in these + # filenames are zero-padded. + videoFrame.save(tmpPath("notes", "frame%d.png" % self.frameNum)) + self.frameNum += 1 + if not DEBUG and self.frameNum % 10 == 0: + sys.stdout.write(".") + sys.stdout.flush() + + self.__timecode.goToNextNote() + + + def __makeFrame (self, numFrame, among): + debug(" writing frame %d" % (self.frameNum)) + + videoFrame = Image.new("RGB", (self.width,self.height), "white") + scoreFrame = self.__scoreImage.makeFrame(numFrame, among) + w, h = scoreFrame.size + videoFrame.paste(scoreFrame,(0,self.height-h,w,self.height)) + for media in self.__medias : + mediaFrame = media.makeFrame(numFrame, among) + wm, hm = mediaFrame.size + w = max(w,wm) + h += hm + videoFrame.paste(mediaFrame, (0,self.height-h,wm,self.height-h+hm)) + return videoFrame + + +class BlankScoreImageError (Exception): + pass + +class Media (object): + + """ + Abstract class which is handled by the VideoFrameWriter. ScoreImage + and SlideShow classes inherit from it. + """ + + def __init__ (self, width = 1280, height = 720): + self.__width = width + self.__height = height + + @property + def width (self): + return self.__width + + @property + def height (self): + return self.__height + + def makeFrame (self, numframe, among): + pass + + def update (self, timecode): + pass + +class ScoreImage (Media): + + """ + This class manages: + - the image following: currentXposition(), travelToNextNote(), + moveToNextNote(), notesXpositions methods + - the frame drawing: the makeFrame() method. + This class handles the 'measure cursor', a new type of cursor. + """ + + def __init__ (self, width, height, picture, notesXpostions, measuresXpositions, leftMargin = 50, rightMargin = 50, scrollNotes = False, noteCursor = True): + """ + Params: + - width: pixel width of frames (and video) + - height: pixel height of frames (and video) + - picture: the long width picture + - notesXpostions: positions in pixel of notes + - measuresXpositions:positions in pixel of measures bars + - leftMargin: left margin for cursor when + cursor scrolling mode is enabled + - rightMargin: right margin for cursor when + cursor scrolling mode is enabled + - scrollNotes: False selects cursor scrolling mode, + True selects note scrolling mode + """ + Media.__init__(self, width, height) + self.__picture = picture + self.__notesXpositions = notesXpostions + if len(self.__notesXpositions) > 0 : + self.__notesXpositions.append(self.__notesXpositions[-1]) + self.__measuresXpositions = measuresXpositions + #self.__measuresXpositions.append(self.__measuresXpositions[-1]) + self.__currentMeasureIndex = 0 + self.__currentNotesIndex = 0 + self.__topCroppable = None + self.__bottomCroppable = None + self.leftMargin = leftMargin + self.rightMargin = rightMargin + self.__leftEdge = None + self.__cropTop = None + self.__cropBottom = None + self.__noteCursor = noteCursor + self.scrollNotes = scrollNotes + self.cursorLineColor = (255,0,0) + + @property + def currentXposition (self): + return self.__notesXpositions[self.__currentNotesIndex] + + @property + def travelToNextNote (self): + return self.__notesXpositions[self.__currentNotesIndex+1] - self.__notesXpositions[self.__currentNotesIndex] + + def moveToNextNote (self): + self.__currentNotesIndex += 1 + if self.__measuresXpositions: + if self.currentXposition > self.__measuresXpositions[self.__currentMeasureIndex+1] : + self.__currentMeasureIndex += 1 + + @property + def notesXpostions (self): + return self.__notesXpositions + + @property + def picture (self): + return self.__picture + + def __setCropTopAndBottom(self): + """ + set the y-coordinates of the top and + bottom edges of the cropping rectangle, relative to the given + (non-cropped) image. + """ + if self.__cropTop is not None and self.__cropBottom is not None: return + picture_width, picture_height = self.__picture.size + + bottomY = picture_height - self.bottomCroppable + progress(" Image height: %5d pixels" % picture_height) + progress(" Top margin size: %5d pixels" % self.topCroppable) + progress("Bottom margin size: %5d pixels (y=%d)" % + (self.bottomCroppable, bottomY)) + + nonWhiteRows = picture_height - self.topCroppable - self.bottomCroppable + progress("Visible content is formed of %d non-white rows of pixels" % + nonWhiteRows) + + # y-coordinate of centre of the visible content, relative to + # the original non-cropped image + nonWhiteCentre = self.topCroppable + int(round(nonWhiteRows/2)) + progress("Centre of visible content is %d pixels from top" % + nonWhiteCentre) + + # Now choose top/bottom cropping coordinates which center + # the content in the video frame. + self.__cropTop = nonWhiteCentre - int(round(self.height / 2)) + self.__cropBottom = self.__cropTop + self.height + + # Figure out the maximum height allowed which keeps the + # cropping rectangle within the source image. + maxTopHalf = self.topCroppable + nonWhiteRows / 2 + maxBottomHalf = self.bottomCroppable + nonWhiteRows / 2 + maxHeight = min(maxTopHalf, maxBottomHalf) * 2 + + if self.__cropTop < 0: + fatal("Would have to crop %d pixels above top of image! " + "Try increasing the resolution DPI (option -r)" + "(which would increase the size of the PNG to be cropped), " + "or reducing the video height to at most %d (option -y)." % + (-self.__cropTop, maxHeight)) + self.__cropTop = 0 + + if self.__cropBottom > picture_height: + fatal("Would have to crop %d pixels below bottom of image! " + "Try increasing the resolution DPI (option -r)" + "(which would increase the size of the PNG to be cropped), " + "or reducing the video height to at most %d (option -y)." % + (self.__cropBottom - picture_height, maxHeight)) + self.__cropBottom = picture_height + + if self.__cropTop > self.topCroppable: + fatal("Would have to crop %d pixels below top of visible content! " + "Try increasing the video height to at least %d (option -y), " + "or decreasing the resolution DPI (option -r)." + % (self.__cropTop - self.topCroppable, nonWhiteRows)) + self.__cropTop = self.topCroppable + + if self.__cropBottom < bottomY: + fatal("Would have to crop %d pixels above bottom of visible content! " + "Try increasing the video height to at least %d (option -y), " + "or decreasing the resolution DPI (option -r)." + % (bottomY - self.__cropBottom, nonWhiteRows)) + self.__cropBottom = bottomY + + progress("Will crop from y=%d to y=%d" % (self.__cropTop, self.__cropBottom)) + + def __cropFrame(self,index): + self.__setCropTopAndBottom() + picture_width, picture_height = self.__picture.size + + if self.scrollNotes: + # Get frame from image of staff + centre = self.width / 2 + left = int(index - centre) + right = int(index + centre) + frame = self.picture.copy().crop((left, self.__cropTop, right, self.__cropBottom)) + cursorX = centre + else: + if self.__leftEdge is None: + # first frame + staffX, staffYs = findStaffLinesInImage(self.picture, 50) + self.__leftEdge = staffX - self.leftMargin + + cursorX = index - self.__leftEdge + debug(" left edge at %d, cursor at %d" % + (self.__leftEdge, cursorX)) + if cursorX > self.width - self.rightMargin: + self.__leftEdge = index - self.leftMargin + cursorX = index - self.__leftEdge + debug(" <<< left edge at %d, cursor at %d" % + (self.__leftEdge, cursorX)) + if picture_width - self.__leftEdge < self.width : + self.__leftEdge = picture_width - self.width + # the cursor has to finish its travel in the last picture cropping + self.rightMargin = 0 + rightEdge = self.__leftEdge + self.width + frame = self.picture.copy().crop((self.__leftEdge, self.__cropTop, + rightEdge, self.__cropBottom)) + return (frame,cursorX) + + def makeFrame (self, numFrame, among): + startIndex = self.currentXposition + indexTravel = self.travelToNextNote + travelPerFrame = float(indexTravel) / among + index = startIndex + int(round(numFrame * travelPerFrame)) + + scoreFrame, cursorX = self.__cropFrame(index) + + # Cursors + if self.__measuresXpositions : + origin = index - cursorX + start = self.__measuresXpositions[self.__currentMeasureIndex] - origin + end = self.__measuresXpositions[self.__currentMeasureIndex + 1] - origin + writeMeasureCursor(scoreFrame, start, end, self.cursorLineColor) + elif self.__noteCursor: + writeCursorLine(scoreFrame, cursorX, self.cursorLineColor) + + return scoreFrame + + def __isLineBlank(self, pixels, width, y): + """ + Returns True if the line with the given y coordinate + is entirely white. + """ + for x in xrange(width): + if pixels[x, y] != (255, 255, 255): + return False + return True + + def __setTopCroppable (self): + # This is way faster than width*height invocations of getPixel() + picture_width, picture_height = self.__picture.size + pixels = self.__picture.load() + progress("Auto-detecting top margin; this may take a while ...") + self.__topCroppable = 0 + for y in xrange(picture_height): + if y == picture_height - 1: + raise BlankScoreImageError + if self.__isLineBlank(pixels, picture_width, y): + self.__topCroppable += 1 + else: + break + + def __setBottomCroppable (self): + # This is way faster than width*height invocations of getPixel() + picture_width, picture_height = self.__picture.size + pixels = self.__picture.load() + progress("Auto-detecting top margin; this may take a while ...") + self.__bottomCroppable = 0 + for y in xrange(picture_height - 1, -1, -1): + if y == 0: + raise BlankScoreImageError + if self.__isLineBlank(pixels, picture_width, y): + self.__bottomCroppable += 1 + else: + break + + @property + def topCroppable (self): # raises BlankScoreImageError + if self.__topCroppable is None: + self.__setTopCroppable() + return self.__topCroppable + + @property + def bottomCroppable (self): + if self.__bottomCroppable is None: + self.__setBottomCroppable() + return self.__bottomCroppable + + def update (self, timecode): + self.moveToNextNote() + +class SlideShow (Media): + + """ + This class is needed to run show composed of several pictures as + the music is playing. A horizontal line cursor can be added if needed. + """ + + def __init__(self, fileNamePrefix, cursorPos = None, lastOffset = None): + self.__fileNamePrefix = fileNamePrefix + self.__fileName = "%s%09.4f.png" % (self.__fileNamePrefix,0.0) + self.__slide = Image.open(self.__fileName) + Media.__init__(self,self.__slide.size[0], self.__slide.size[1]) + self.cursorLineColor = (255,0,0) + + # get cursor travelling data + if cursorPos and lastOffset: + self.__cursorStart = float(cursorPos[0]) + self.__cursorEnd = float(cursorPos[1]) + self.__lastOffset = lastOffset + self.__scale = (self.__cursorEnd - self.__cursorStart)/self.__lastOffset + else: + self.__cursorStart = None + + + self.startOffset = 0.0 + self.endOffset = 0.0 + + def makeFrame (self, numFrame, among): + # We check if the slide must change + start = self.startOffset * self.__scale + end = self.endOffset * self.__scale + travelPerFrame = float(end - start) / among + index = start + int(round(numFrame * travelPerFrame)) + self.__cursorStart + + newFileName = "%s%09.4f.png" % (self.__fileNamePrefix,self.startOffset) + if newFileName != self.__fileName: + self.__fileName = newFileName + if os.path.exists(self.__fileName): + self.__slide = Image.open(self.__fileName) + debug ("Add slide from file " + self.__fileName) + tmpSlide = self.__slide.copy() + writeCursorLine(tmpSlide, int(index), self.cursorLineColor) + return tmpSlide + + def update(self, timecode): + self.startOffset = timecode.currentOffset + self.endOffset = timecode.nextOffset +