From f923c393c73524ab4f320b40c15c5711bde2dc0e Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 7 Jan 2014 11:09:59 +0200 Subject: [PATCH 01/29] .gitignore updated Eclipse metadata, Emacs *~ and .pyc are now ignored. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) 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 From c65a8cc0583a0a014df9cda10e3883eeb8780ba3 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 7 Jan 2014 10:18:27 +0100 Subject: [PATCH 02/29] unit tests: new test.py, using unittest python lib Tested functions: * findStaffLinesInImage() Tested classes: * VideoFrameWriter: - image handling methods: . isLineBlank() . getTopAndBottomMarginSizes() . getCropTopAndBottom() . cropFrame() - time handling methods: . ticksToSecs() . secsElapsedForTempoChanges() --- test.py | 212 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 test.py diff --git a/test.py b/test.py new file mode 100644 index 0000000..933b87c --- /dev/null +++ b/test.py @@ -0,0 +1,212 @@ +#!/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 ly2video import VideoFrameWriter +import ly2video +from PIL import Image + +class VideoFrameWriterTest(unittest.TestCase): + + def setUp(self): + self.frameWriter = VideoFrameWriter( + width = 16, + height = 16, + fps = 30.0, + cursorLineColor = (255,0,0), + scrollNotes = False, + leftMargin = 50, + rightMargin = 100, + 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 testIsLineBlank (self): + pixels = self.image.load() + #pixels = [[(255,255,255),(255,255,255),(255,255,255)],[(255,255,255),(255,255,255),(255,255,255)],[(255,255,255),(255,255,255),(255,255,255)]] + w, h = self.image.size + self.assertTrue(self.frameWriter.isLineBlank(pixels, w, 0), "Line should be blank") + self.assertFalse(self.frameWriter.isLineBlank(pixels, w, 8), "Line should not be blank") + + def testIsLineBlank_withLineAlmostBlack (self): + w, h = self.image.size + pixels = self.image.load() + for x in range(15) : self.image.putpixel((x,10),(0,0,0)) + self.assertFalse(self.frameWriter.isLineBlank(pixels, w, 10), "Line should not be blank") + + def testIsLineBlank_withLineAlmostBlank (self): + w, h = self.image.size + pixels = self.image.load() + self.image.putpixel((4,4),(0,0,0)) + self.assertFalse(self.frameWriter.isLineBlank(pixels, w, 4), "Line should not be blank") + + def testGetTopAndBottomMarginSizes_withBlackImage (self): + image = Image.new("RGB",(16,16),(0,0,0)) + topMarginSize, bottomMarginSize = self.frameWriter.getTopAndBottomMarginSizes(image) + self.assertEqual(topMarginSize, 0, "Bad topMarginSize") + self.assertEqual(bottomMarginSize, 0, "Bad bottomMarginSize") + + def testGetTopAndBottomMarginSizes_withBlankImage (self): + image = Image.new("RGB",(16,16),(255,255,255)) + with self.assertRaises(SystemExit) as cm: + topMarginSize, bottomMarginSize = self.frameWriter.getTopAndBottomMarginSizes(image) + self.assertEqual(cm.exception.code, 1) + + def testGetTopAndBottomMarginSizes_withHorizontalBlackLine (self): + image = Image.new("RGB",(16,16),(255,255,255)) + for x in range(16) : image.putpixel((x,8),(0,0,0)) + topMarginSize, bottomMarginSize = self.frameWriter.getTopAndBottomMarginSizes(image) + self.assertEqual(topMarginSize, 8, "Bad topMarginSize") + self.assertEqual(bottomMarginSize, 7, "Bad bottomMarginSize") + + def testGetTopAndBottomMarginSizes_withBlackPoint (self): + image = Image.new("RGB",(16,16),(255,255,255)) + image.putpixel((8,8),(0,0,0)) + topMarginSize, bottomMarginSize = self.frameWriter.getTopAndBottomMarginSizes(image) + self.assertEqual(topMarginSize, 8, "Bad topMarginSize") + self.assertEqual(bottomMarginSize, 7, "Bad bottomMarginSize") + + def testGetCropTopAndBottom_withBlackImage(self): + self.frameWriter.height = 16 + image = Image.new("RGB",(16,16),(0,0,0)) + cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + self.assertEqual(cropTop, 0, "Bad cropTop!") + self.assertEqual(cropBottom, 16, "Bad cropBottom!") + + def testGetCropTopAndBottom_withBlackImageTooSmall(self): + self.frameWriter.height = 17 + image = Image.new("RGB",(16,16),(0,0,0)) + with self.assertRaises(SystemExit) as cm: + cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + self.assertEqual(cm.exception.code, 1) + + def testGetCropTopAndBottom_withBlackImageTooBig(self): + self.frameWriter.height = 15 + image = Image.new("RGB",(16,16),(0,0,0)) + with self.assertRaises(SystemExit) as cm: + cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + self.assertEqual(cm.exception.code, 1) + + def testGetCropTopAndBottom_withBlackPoint (self): + image = Image.new("RGB",(16,16),(255,255,255)) + image.putpixel((8,8),(0,0,0)) + self.frameWriter.height = 9 + cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + self.assertEqual(cropTop, 4, "Bad cropTop!") + self.assertEqual(cropBottom, 13, "Bad cropBottom!") + + def testGetCropTopAndBottom_withVideoHeightTooSmall (self): + self.frameWriter.height = 20 + image = Image.new("RGB",(30,30),(255,255,255)) + image.putpixel((8,4),(0,0,0)) + image.putpixel((8,12),(0,0,0)) + with self.assertRaises(SystemExit) as cm: + cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + self.assertEqual(cm.exception.code, 1) + + def testGetCropTopAndBottom_withNonCenteredContent (self): + image = Image.new("RGB",(16,16),(255,255,255)) + image.putpixel((8,4),(0,0,0)) + image.putpixel((8,12),(0,0,0)) + self.frameWriter.height = 8 + with self.assertRaises(SystemExit) as cm: + cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + self.assertEqual(cm.exception.code, 1) + + def testTicksToSecs (self): + for tempo in range (1,300): + self.frameWriter.tempo = float(tempo) + secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 0) + self.assertEqual(secsSinceStartIndex, 0.0, "") + self.frameWriter.tempo = 60.0 + secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 384) + self.assertEqual(secsSinceStartIndex, 1.0, "") + secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 768) + self.assertEqual(secsSinceStartIndex, 2.0, "") + self.frameWriter.tempo = 90.0 + secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 1152) + self.assertEqual(secsSinceStartIndex, 2.0, "") + secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 3456) + self.assertEqual(secsSinceStartIndex, 6.0, "") + + def testSecsElapsedForTempoChanges (self): + self.frameWriter.temposList = [(0, 60.0),(1152, 90.0),(3456, 60.0)] + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 1152, startIndex = 0, endIndex = 2) + self.assertEqual(result,3.0,"") + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 3456, startIndex = 0, endIndex = 2) + self.assertEqual(result,6.0,"") + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 1152, endTick = 3456, startIndex = 0, endIndex = 2) + self.assertEqual(result,4.0,"") + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 3456, endTick = 4608, startIndex = 0, endIndex = 2) + self.assertEqual(result,3.0,"") + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 4608, startIndex = 0, endIndex = 2) + self.assertEqual(result,12.0,"") + + 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 = ly2video.findStaffLinesInImage(image, 50) + self.assertEqual(staffX, 23, "") + self.assertEqual(staffYs[0], 20, "") + + def testCropFrame (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)) + #cropTop, cropBottom = self.getCropTopAndBottom(image) + self.frameWriter.width=200 + self.frameWriter.leftMargin = 50 + self.frameWriter.rightMargin = 50 + index = 70 + frame, cursorX = self.frameWriter.cropFrame(notesPic = image, index = index, top = 10, bottom = 190) + w,h = frame.size + print "cursorX = %d, width = %d, height = %d" % (cursorX,w,h) + self.assertEqual(w, 200, "") + self.assertEqual(h, 180, "") + self.assertEqual(cursorX, self.frameWriter.leftMargin - (ox+3) + index%(self.frameWriter.width-self.frameWriter.rightMargin) , "") + self.assertEqual(cursorX, 97 , "") + + def testCropFrame_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)) + #cropTop, cropBottom = self.getCropTopAndBottom(image) + self.frameWriter.width=200 + self.frameWriter.leftMargin = 50 + self.frameWriter.rightMargin = 50 + index = 200 + frame, cursorX = self.frameWriter.cropFrame(notesPic = image, index = index, top = 10, bottom = 190) + w,h = frame.size + print "cursorX = %d, width = %d, height = %d" % (cursorX,w,h) + self.assertEqual(w, 200, "") + self.assertEqual(h, 180, "") + self.assertEqual(cursorX, index%(self.frameWriter.width-self.frameWriter.rightMargin) , "") + self.assertEqual(cursorX, 50 , "") + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From b339675b9ad08ecee14d33a1603c05950db30835 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 7 Jan 2014 13:09:20 +0100 Subject: [PATCH 03/29] refactor: add 'utils' module for some utility functions To maintain the code more easily, the program will be split into several modules. setDebug() and setRunDir() functions are handling global var DEBUG and runDir. --- ly2video.py | 65 +++--------------------------------- utils.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 61 deletions(-) create mode 100644 utils.py diff --git a/ly2video.py b/ly2video.py index 6029401..63610df 100755 --- a/ly2video.py +++ b/ly2video.py @@ -43,10 +43,10 @@ from ly.tokenize import MusicTokenizer, Tokenizer import ly.tools import midi +from utils import * from pprint import pprint, pformat -DEBUG = False # --debug sets to True GLOBAL_STAFF_SIZE = 20 @@ -1238,63 +1238,6 @@ 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 - -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 tmpPath(*dirs): - segments = [ 'ly2video.tmp' ] - segments.extend(dirs) - return os.path.join(runDir, *segments) - def parseOptions(): parser = OptionParser("usage: %prog [options]") @@ -1379,8 +1322,7 @@ def parseOptions(): fatal("Must specify --title-ttf=FONT-FILE with --title-at-start.") if options.debug: - global DEBUG - DEBUG = True + setDebug() return options, args @@ -1866,7 +1808,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()) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..4dca7ff --- /dev/null +++ b/utils.py @@ -0,0 +1,95 @@ +#!/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) + From bff350b2b855b6688f679757c86462a0d47d41f8 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 7 Jan 2014 13:11:46 +0100 Subject: [PATCH 04/29] refactor: add 'video' module Move VideoFrameWriter and some image scanning functions into a new 'video' module. Update unit tests. --- ly2video.py | 483 +------------------------------------------------ test.py | 5 +- video.py | 512 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 515 insertions(+), 485 deletions(-) create mode 100644 video.py diff --git a/ly2video.py b/ly2video.py index 63610df..5b44013 100755 --- a/ly2video.py +++ b/ly2video.py @@ -44,6 +44,7 @@ import ly.tools import midi from utils import * +from video import * from pprint import pprint, pformat @@ -262,44 +263,6 @@ 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. - - 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 findStaffLines(imageFile, lineLength): """ Takes a image and returns y co-ordinates of staff lines in pixels. @@ -316,45 +279,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. @@ -769,411 +693,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. diff --git a/test.py b/test.py index 933b87c..eca80c8 100644 --- a/test.py +++ b/test.py @@ -23,8 +23,7 @@ # . import unittest -from ly2video import VideoFrameWriter -import ly2video +from video import * from PIL import Image class VideoFrameWriterTest(unittest.TestCase): @@ -170,7 +169,7 @@ def testSecsElapsedForTempoChanges (self): 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 = ly2video.findStaffLinesInImage(image, 50) + staffX, staffYs = findStaffLinesInImage(image, 50) self.assertEqual(staffX, 23, "") self.assertEqual(staffYs[0], 20, "") diff --git a/video.py b/video.py new file mode 100644 index 0000000..e9bc460 --- /dev/null +++ b/video.py @@ -0,0 +1,512 @@ +#!/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 * +import os +from PIL import Image + +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. + """ + + 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 + + self.runDir = None + + 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 From faf9b49e9498bbf43c169b40a81a53f05e0f36b2 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 7 Jan 2014 13:13:12 +0100 Subject: [PATCH 05/29] add initialisation of index variable in getNoteIndices() Avoid an 'undefined var' at execution? --- ly2video.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ly2video.py b/ly2video.py index 5b44013..13c64cc 100755 --- a/ly2video.py +++ b/ly2video.py @@ -574,6 +574,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" % From 713eda4597fcb7be2e28f22ff1bed40e9cdcbb6f Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 7 Jan 2014 13:14:21 +0100 Subject: [PATCH 06/29] update authors --- ly2video.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ly2video.py b/ly2video.py index 13c64cc..a7f8e56 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 @@ -863,7 +864,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() From 5fda08a96d925fe7a121bc57bad7ebb59f9db7ca Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 7 Jan 2014 22:21:00 +0100 Subject: [PATCH 07/29] refactor: new 'ScoreImage' class managing score image cropping and travelling Some VideoFrameWriter methods are now moved to this class. The getCropTopAndBottomMarginSizes() method is replaced by the topCroppable() and bottomCroppable() methods which respectivly return the number of pixels the image can be cropped on top an bottom. An instance is now used by VideoFrameWriter. Some unused code has been removed in VideoFrameWriter (write and secsElapsedForTempoChanges) --- video.py | 441 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 247 insertions(+), 194 deletions(-) diff --git a/video.py b/video.py index e9bc460..128d43e 100644 --- a/video.py +++ b/video.py @@ -26,6 +26,14 @@ 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 findTopStaffLine(image, lineLength): """ Returns the coordinates of the left-most pixel in the top line of @@ -165,6 +173,8 @@ def __init__(self, width, height, fps, cursorLineColor, self.rightMargin = rightMargin self.runDir = None + + self.__scoreImage = None def estimateFrames(self): approxBeats = float(self.midiTicks[-1]) / self.midiResolution @@ -182,6 +192,13 @@ def write(self, indices, notesImage): - indices: indices of notes in pictures - notesImage: filename of the image """ + + self.__scoreImage = ScoreImage(Image.open(notesImage), indices, self.width, self.height) + self.__scoreImage.leftMargin = self.leftMargin + self.__scoreImage.rightMargin = self.rightMargin + self.__scoreImage.scrollNotes = self.scrollNotes + self.__scoreImage.cursorLineColor = self.cursorLineColor + # folder to store frames for video if not os.path.exists("notes"): os.mkdir("notes") @@ -190,9 +207,6 @@ def write(self, indices, notesImage): 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]) @@ -208,19 +222,13 @@ def write(self, indices, notesImage): # calculate it like this in order to ensure tempoIndex is # correct before we start writing frames. silentPreludeDuration = \ - self.secsElapsedForTempoChanges(0, initialTick, - 0, indices[0]) + self.secsElapsedForTempoChanges(0, initialTick) # 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)) +# 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] @@ -235,8 +243,7 @@ def write(self, indices, notesImage): # many frames we need in between the current pair of # indices. secsSinceIndex = \ - self.secsElapsedForTempoChanges(startTick, endTick, - startIndex, endIndex) + self.secsElapsedForTempoChanges(startTick, endTick) # This is the exact time we are *aiming* for the frameset # to finish at (i.e. the start time of the first frame @@ -247,8 +254,8 @@ def write(self, indices, notesImage): # error and we'll miss our target by a small amount. targetSecs = self.secs + secsSinceIndex - debug(" secs at new index %d: %f" % - (endIndex, targetSecs)) +# 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 @@ -264,145 +271,17 @@ def write(self, indices, notesImage): neededFrames = int(round(neededFrameSetSecs * self.fps)) if neededFrames > 0: - self.writeVideoFrames( - neededFrames, startIndex, indexTravel, - notesPic, cropTop, cropBottom) + self.writeVideoFrames(neededFrames) # Update time in the *ideal* (i.e. not real) world - this # is totally independent of fps. self.secs = targetSecs - + self.__scoreImage.moveToNextNote() 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): + 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 @@ -426,87 +305,261 @@ def secsElapsedForTempoChanges(self, startTick, endTick, # startTick < tempoTick < endTick secsSinceStartIndex += self.ticksToSecs(lastTick, tempoTick) debug(" last %d tempo %d" % (lastTick, tempoTick)) - debug(" secs since index %d: %f" % - (startIndex, secsSinceStartIndex)) + 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)) +# debug(" secs between indices %d and %d: %f" % +# (startIndex, endIndex, secsSinceStartIndex)) return secsSinceStartIndex - def writeVideoFrames(self, neededFrames, startIndex, indexTravel, - notesPic, cropTop, cropBottom): + def writeVideoFrames(self, neededFrames): """ 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) + debug(" writing frame %d" % (self.frameNum)) + scoreFrame = self.__scoreImage.makeFrame(numFrame = i, among = neededFrames) + w, h = scoreFrame.size + frame = Image.new("RGB", (w,h), "white") + frame.paste(scoreFrame,(0,0,w,h)) + # 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() + sys.stdout.flush() - def cropFrame(self, notesPic, index, top, bottom): + 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 + +class BlankScoreImageError (Exception): + pass + +class Media (object): + + 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 + +class ScoreImage (Media): + + def __init__ (self, picture, notesXpostions, areaWidth, areaHeight): + Media.__init__(self,picture.size[0], picture.size[1]) + self.__picture = picture + self.__notesXpositions = notesXpostions + self.__currentNotesIndex = 0 + self.__topCroppable = None + self.__bottomCroppable = None + self.leftMargin = 50 + self.rightMargin = 50 + self.areaWidth = areaWidth + self.areaHeight = areaHeight + self.__leftEdge = None + self.__cropTop = None + self.__cropBottom = None + self.scrollNotes = False + self.cursorLineColor = (255,0,0) + self.neededFrame = None + + @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 + + @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 + + bottomY = self.height - self.bottomCroppable + progress(" Image height: %5d pixels" % self.height) + progress(" Top margin size: %5d pixels" % self.topCroppable) + progress("Bottom margin size: %5d pixels (y=%d)" % + (self.bottomCroppable, bottomY)) + + nonWhiteRows = self.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.areaHeight / 2)) + self.__cropBottom = self.__cropTop + self.areaHeight + + # 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 " + "(which would increase the size of the PNG to be cropped), " + "or reducing the video height to at most %d" % + (-self.__cropTop, maxHeight)) + self.__cropTop = 0 + + if self.__cropBottom > self.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" % + (self.__cropBottom - self.height, maxHeight)) + self.__cropBottom = self.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, " + "or decreasing the resolution DPI." + % (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, " + "or decreasing the resolution DPI." + % (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() 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)) + frame = self.picture.copy().crop((left, self.__cropTop, right, self.__cropBottom)) cursorX = centre else: - if self.leftEdge is None: + if self.__leftEdge is None: # first frame - staffX, staffYs = findStaffLinesInImage(notesPic, 50) - self.leftEdge = staffX - self.leftMargin + staffX, staffYs = findStaffLinesInImage(self.picture, 50) + self.__leftEdge = staffX - self.leftMargin - cursorX = index - self.leftEdge + 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 + (self.__leftEdge, cursorX)) + if cursorX > self.areaWidth - self.rightMargin: + self.__leftEdge = index - self.leftMargin + cursorX = index - self.__leftEdge debug(" <<< left edge at %d, cursor at %d" % - (self.leftEdge, cursorX)) + (self.__leftEdge, cursorX)) - rightEdge = self.leftEdge + self.width - frame = notesPic.copy().crop((self.leftEdge, top, - rightEdge, bottom)) - return frame, cursorX + rightEdge = self.__leftEdge + self.areaWidth + frame = self.picture.copy().crop((self.__leftEdge, self.__cropTop, + rightEdge, self.__cropBottom)) + 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 makeFrame (self, numFrame, among): + startIndex = self.currentXposition + indexTravel = self.travelToNextNote + travelPerFrame = float(indexTravel) / among + index = startIndex + int(round(numFrame * travelPerFrame)) - 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)) + scoreFrame, cursorX = self.__cropFrame(index) - secsSinceTick = beatsSinceTick * 60.0 / self.tempo - debug(" secs from tick %d -> %d: %f (%.3f bpm)" % - (startTick, endTick, secsSinceTick, self.tempo)) + # Cursor + writeCursorLine(scoreFrame, cursorX, self.cursorLineColor) - return secsSinceTick + 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() + pixels = self.__picture.load() + progress("Auto-detecting top margin; this may take a while ...") + self.__topCroppable = 0 + for y in xrange(self.height): + if y == self.height - 1: + raise BlankScoreImageError + if self.__isLineBlank(pixels, self.width, y): + self.__topCroppable += 1 + else: + break + + def __setBottomCroppable (self): + # This is way faster than width*height invocations of getPixel() + pixels = self.__picture.load() + progress("Auto-detecting top margin; this may take a while ...") + self.__bottomCroppable = 0 + for y in xrange(self.height - 1, -1, -1): + if y == 0: + raise BlankScoreImageError + if self.__isLineBlank(pixels, self.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 + From fdfe0abd2aa9dfe9457847ec071f95d389efbe35 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 7 Jan 2014 22:26:48 +0100 Subject: [PATCH 08/29] unit tests: 'ScoreImage' testing Update the tests for the new class ScoreImage. The tests of image handling methods are moved to the ScoreImageTest test class. Tested functions: * findStaffLinesInImage() Tested classes: * VideoFrameWriter: . ticksToSecs() . secsElapsedForTempoChanges() * ScoreImage . __isLineBlank() . __setCropTopAndBottom() . __cropFrame() . topCroppable() . bottomCroppable() . makeFrame() --- test.py | 287 ++++++++++++++++++++++++++++++++------------------------ 1 file changed, 165 insertions(+), 122 deletions(-) diff --git a/test.py b/test.py index eca80c8..750cc08 100644 --- a/test.py +++ b/test.py @@ -25,117 +25,193 @@ import unittest from video import * from PIL import Image - -class VideoFrameWriterTest(unittest.TestCase): +class ScoreImageTest (unittest.TestCase): + def setUp(self): - self.frameWriter = VideoFrameWriter( - width = 16, - height = 16, - fps = 30.0, - cursorLineColor = (255,0,0), - scrollNotes = False, - leftMargin = 50, - rightMargin = 100, - 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 testIsLineBlank (self): - pixels = self.image.load() - #pixels = [[(255,255,255),(255,255,255),(255,255,255)],[(255,255,255),(255,255,255),(255,255,255)],[(255,255,255),(255,255,255),(255,255,255)]] - w, h = self.image.size - self.assertTrue(self.frameWriter.isLineBlank(pixels, w, 0), "Line should be blank") - self.assertFalse(self.frameWriter.isLineBlank(pixels, w, 8), "Line should not be blank") - - def testIsLineBlank_withLineAlmostBlack (self): - w, h = self.image.size - pixels = self.image.load() - for x in range(15) : self.image.putpixel((x,10),(0,0,0)) - self.assertFalse(self.frameWriter.isLineBlank(pixels, w, 10), "Line should not be blank") - - def testIsLineBlank_withLineAlmostBlank (self): - w, h = self.image.size - pixels = self.image.load() - self.image.putpixel((4,4),(0,0,0)) - self.assertFalse(self.frameWriter.isLineBlank(pixels, w, 4), "Line should not be blank") - - def testGetTopAndBottomMarginSizes_withBlackImage (self): - image = Image.new("RGB",(16,16),(0,0,0)) - topMarginSize, bottomMarginSize = self.frameWriter.getTopAndBottomMarginSizes(image) - self.assertEqual(topMarginSize, 0, "Bad topMarginSize") - self.assertEqual(bottomMarginSize, 0, "Bad bottomMarginSize") + image = Image.new("RGB",(1000,200),(255,255,255)) + self.blankImage = ScoreImage(image, [], 1000,200) + 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(image, [], 1000,200) - def testGetTopAndBottomMarginSizes_withBlankImage (self): + # PRIVATE METHODS + # __isLineBlank + def test__IsLineBlank (self): image = Image.new("RGB",(16,16),(255,255,255)) - with self.assertRaises(SystemExit) as cm: - topMarginSize, bottomMarginSize = self.frameWriter.getTopAndBottomMarginSizes(image) - self.assertEqual(cm.exception.code, 1) + 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 testGetTopAndBottomMarginSizes_withHorizontalBlackLine (self): + def test__IsLineBlank_withLineAlmostBlack (self): image = Image.new("RGB",(16,16),(255,255,255)) - for x in range(16) : image.putpixel((x,8),(0,0,0)) - topMarginSize, bottomMarginSize = self.frameWriter.getTopAndBottomMarginSizes(image) - self.assertEqual(topMarginSize, 8, "Bad topMarginSize") - self.assertEqual(bottomMarginSize, 7, "Bad bottomMarginSize") + 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 testGetTopAndBottomMarginSizes_withBlackPoint (self): + def test__IsLineBlank_withLineAlmostBlank (self): image = Image.new("RGB",(16,16),(255,255,255)) - image.putpixel((8,8),(0,0,0)) - topMarginSize, bottomMarginSize = self.frameWriter.getTopAndBottomMarginSizes(image) - self.assertEqual(topMarginSize, 8, "Bad topMarginSize") - self.assertEqual(bottomMarginSize, 7, "Bad bottomMarginSize") - - def testGetCropTopAndBottom_withBlackImage(self): - self.frameWriter.height = 16 - image = Image.new("RGB",(16,16),(0,0,0)) - cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) - self.assertEqual(cropTop, 0, "Bad cropTop!") - self.assertEqual(cropBottom, 16, "Bad cropBottom!") - - def testGetCropTopAndBottom_withBlackImageTooSmall(self): - self.frameWriter.height = 17 - image = Image.new("RGB",(16,16),(0,0,0)) + 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(Image.new("RGB",(16,16),(0,0,0)), [], 16, 16) + 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(Image.new("RGB",(16,16),(0,0,0)), [], 16, 17) with self.assertRaises(SystemExit) as cm: - cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + blackImage._ScoreImage__setCropTopAndBottom() self.assertEqual(cm.exception.code, 1) - def testGetCropTopAndBottom_withBlackImageTooBig(self): - self.frameWriter.height = 15 - image = Image.new("RGB",(16,16),(0,0,0)) + def test__setCropTopAndBottom_withBlackImageTooBig(self): + blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], 16, 15) with self.assertRaises(SystemExit) as cm: - cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + blackImage._ScoreImage__setCropTopAndBottom() self.assertEqual(cm.exception.code, 1) - def testGetCropTopAndBottom_withBlackPoint (self): + def test__setCropTopAndBottom_withBlackPoint(self): image = Image.new("RGB",(16,16),(255,255,255)) image.putpixel((8,8),(0,0,0)) - self.frameWriter.height = 9 - cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) - self.assertEqual(cropTop, 4, "Bad cropTop!") - self.assertEqual(cropBottom, 13, "Bad cropBottom!") + blackPointImage = ScoreImage(image, [], 16, 9) + blackPointImage._ScoreImage__setCropTopAndBottom() + self.assertEqual(blackPointImage._ScoreImage__cropTop, 4, "Bad cropTop!") + self.assertEqual(blackPointImage._ScoreImage__cropBottom, 13, "Bad cropBottom!") - def testGetCropTopAndBottom_withVideoHeightTooSmall (self): - self.frameWriter.height = 20 + 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(Image.new("RGB",(16,16),(0,0,0)), [], 16, 20) with self.assertRaises(SystemExit) as cm: - cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + scoreImage._ScoreImage__setCropTopAndBottom() self.assertEqual(cm.exception.code, 1) - def testGetCropTopAndBottom_withNonCenteredContent (self): + 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)) - self.frameWriter.height = 8 + scoreImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], 16, 8) with self.assertRaises(SystemExit) as cm: - cropTop, cropBottom = self.frameWriter.getCropTopAndBottom(image) + 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(image, [], 200, 40) + scoreImage.leftMargin = 50 + scoreImage.rightMargin = 50 + 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(image, [], 200, 40) + scoreImage.leftMargin = 50 + scoreImage.rightMargin = 50 + index = 200 + areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index) + w,h = areaFrame.size + self.assertEqual(w, 200, "") + self.assertEqual(h, 40, "") + self.assertEqual(cursorX, 50 , "") + + # PUBLIC METHODS + # topCroppable + def testTopCroppable_withBlackImage (self): + blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], 16, 16) + self.assertEqual(blackImage.topCroppable, 0, "Bad topMarginSize") + + def testTopCroppable_withBlankImage (self): + def testTopCroppable(image): + return image.topCroppable + blankImage = ScoreImage(Image.new("RGB",(16,16),(255,255,255)), [], 16, 16) + 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(image, [], 16, 16) + 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(image, [], 16, 16) + self.assertEqual(blackImage.topCroppable, 8, "Bad topMarginSize") + + # bottomCroppable + def testBottomCroppable_withBlackImage (self): + blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], 16, 16) + self.assertEqual(blackImage.bottomCroppable, 0, "Bad bottomMarginSize") + + def testBottomCroppable_withBlankImage (self): + def testBottomCroppable(image): + return image.bottomCroppable + blankImage = ScoreImage(Image.new("RGB",(16,16),(255,255,255)), [], 16, 16) + 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(image, [], 16, 16) + 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(image, [], 16, 16) + 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(image, [70, 100], 200, 40) + scoreImage.leftMargin = 50 + scoreImage.rightMargin = 50 + areaFrame = scoreImage.makeFrame(numFrame = 10, among = 30) + w,h = areaFrame.size + self.assertEqual(w, 200, "") + self.assertEqual(h, 40, "") +class VideoFrameWriterTest(unittest.TestCase): + + def setUp(self): + self.frameWriter = VideoFrameWriter( + width = 16, + height = 16, + fps = 30.0, + cursorLineColor = (255,0,0), + scrollNotes = False, + leftMargin = 50, + rightMargin = 100, + 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 testTicksToSecs (self): for tempo in range (1,300): @@ -155,15 +231,15 @@ def testTicksToSecs (self): def testSecsElapsedForTempoChanges (self): self.frameWriter.temposList = [(0, 60.0),(1152, 90.0),(3456, 60.0)] - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 1152, startIndex = 0, endIndex = 2) + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 1152) self.assertEqual(result,3.0,"") - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 3456, startIndex = 0, endIndex = 2) + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 3456) self.assertEqual(result,6.0,"") - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 1152, endTick = 3456, startIndex = 0, endIndex = 2) + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 1152, endTick = 3456) self.assertEqual(result,4.0,"") - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 3456, endTick = 4608, startIndex = 0, endIndex = 2) + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 3456, endTick = 4608) self.assertEqual(result,3.0,"") - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 4608, startIndex = 0, endIndex = 2) + result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 4608) self.assertEqual(result,12.0,"") def testFindStaffLinesInImage (self): @@ -173,39 +249,6 @@ def testFindStaffLinesInImage (self): self.assertEqual(staffX, 23, "") self.assertEqual(staffYs[0], 20, "") - def testCropFrame (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)) - #cropTop, cropBottom = self.getCropTopAndBottom(image) - self.frameWriter.width=200 - self.frameWriter.leftMargin = 50 - self.frameWriter.rightMargin = 50 - index = 70 - frame, cursorX = self.frameWriter.cropFrame(notesPic = image, index = index, top = 10, bottom = 190) - w,h = frame.size - print "cursorX = %d, width = %d, height = %d" % (cursorX,w,h) - self.assertEqual(w, 200, "") - self.assertEqual(h, 180, "") - self.assertEqual(cursorX, self.frameWriter.leftMargin - (ox+3) + index%(self.frameWriter.width-self.frameWriter.rightMargin) , "") - self.assertEqual(cursorX, 97 , "") - - def testCropFrame_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)) - #cropTop, cropBottom = self.getCropTopAndBottom(image) - self.frameWriter.width=200 - self.frameWriter.leftMargin = 50 - self.frameWriter.rightMargin = 50 - index = 200 - frame, cursorX = self.frameWriter.cropFrame(notesPic = image, index = index, top = 10, bottom = 190) - w,h = frame.size - print "cursorX = %d, width = %d, height = %d" % (cursorX,w,h) - self.assertEqual(w, 200, "") - self.assertEqual(h, 180, "") - self.assertEqual(cursorX, index%(self.frameWriter.width-self.frameWriter.rightMargin) , "") - self.assertEqual(cursorX, 50 , "") if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 17a84a653156fc9442b571e51d226a32cbb1348d Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Wed, 8 Jan 2014 00:13:05 +0100 Subject: [PATCH 09/29] The loop on frame generation is now based on midi ticks ...instead of image notes indexes. Less dependant from the scoreImage class. --- video.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/video.py b/video.py index 128d43e..6bf6c01 100644 --- a/video.py +++ b/video.py @@ -207,9 +207,6 @@ def write(self, indices, notesImage): debug("first tempo is %.3f bpm" % self.tempo) debug("final MIDI tick is %d" % self.midiTicks[-1]) - # duplicate last index - indices.append(indices[-1]) - self.estimateFrames() progress("Writing frames ...") if not DEBUG: @@ -225,7 +222,7 @@ def write(self, indices, notesImage): self.secsElapsedForTempoChanges(0, initialTick) # generate all frames in between each pair of adjacent indices - for i in xrange(len(indices) - 1): + while self.midiIndex < len(self.midiTicks) - 1: # debug("\nwall-clock secs: %f" % self.secs) # debug("index: %d -> %d (indexTravel %d)" % # (startIndex, endIndex, indexTravel)) @@ -373,6 +370,8 @@ def __init__ (self, picture, notesXpostions, areaWidth, areaHeight): Media.__init__(self,picture.size[0], picture.size[1]) self.__picture = picture self.__notesXpositions = notesXpostions + if len(self.__notesXpositions) > 0 : + self.__notesXpositions.append(self.__notesXpositions[-1]) self.__currentNotesIndex = 0 self.__topCroppable = None self.__bottomCroppable = None From 99f4aeb9d65b5ca13e0ea0543357d940b96f232e Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Wed, 8 Jan 2014 00:48:08 +0100 Subject: [PATCH 10/29] add VideoFrameWriter.scoreImage property The ScoreImage instance of VideoFrameWriter can now be set as a public property. Some parameters of the VideoFrameWriter constructor are removed and the write() method has now no parameter. These parameters are attached to the ScoreImage instance. --- ly2video.py | 6 +++--- test.py | 27 +++++++++++++-------------- video.py | 37 ++++++++++++++++++------------------- 3 files changed, 34 insertions(+), 36 deletions(-) diff --git a/ly2video.py b/ly2video.py index a7f8e56..e3fc63e 100755 --- a/ly2video.py +++ b/ly2video.py @@ -1393,12 +1393,12 @@ def main(): fps = options.fps # generate notes - 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) + leftMargin, rightMargin = options.cursorMargins.split(",") + frameWriter.scoreImage = ScoreImage(Image.open(notesImage), noteIndices, int(leftMargin), int(rightMargin), options.scrollNotes) + frameWriter.write() output_divider_line() wavPath = genWavFile(timidity, midiPath) diff --git a/test.py b/test.py index 750cc08..ca91c5b 100644 --- a/test.py +++ b/test.py @@ -62,7 +62,8 @@ def test__IsLineBlank_withLineAlmostBlank (self): # __setCropTopAndBottom def test__setCropTopAndBottom_withBlackImage(self): - blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], 16, 16) + blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), []) + blackImage.areaHeight = 16 blackImage._ScoreImage__setCropTopAndBottom() self.assertEqual(blackImage._ScoreImage__cropTop, 0, "Bad cropTop!") self.assertEqual(blackImage._ScoreImage__cropBottom, 16, "Bad cropBottom!") @@ -82,7 +83,8 @@ def test__setCropTopAndBottom_withBlackImageTooBig(self): def test__setCropTopAndBottom_withBlackPoint(self): image = Image.new("RGB",(16,16),(255,255,255)) image.putpixel((8,8),(0,0,0)) - blackPointImage = ScoreImage(image, [], 16, 9) + blackPointImage = ScoreImage(image, []) + blackPointImage.areaHeight = 9 blackPointImage._ScoreImage__setCropTopAndBottom() self.assertEqual(blackPointImage._ScoreImage__cropTop, 4, "Bad cropTop!") self.assertEqual(blackPointImage._ScoreImage__cropBottom, 13, "Bad cropBottom!") @@ -110,9 +112,9 @@ 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(image, [], 200, 40) - scoreImage.leftMargin = 50 - scoreImage.rightMargin = 50 + scoreImage = ScoreImage(image, []) + scoreImage.areaWidth = 200 + scoreImage.areaHeight = 40 index = 70 areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index) w,h = areaFrame.size @@ -124,9 +126,9 @@ 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(image, [], 200, 40) - scoreImage.leftMargin = 50 - scoreImage.rightMargin = 50 + scoreImage = ScoreImage(image, []) + scoreImage.areaWidth = 200 + scoreImage.areaHeight = 40 index = 200 areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index) w,h = areaFrame.size @@ -186,9 +188,9 @@ 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(image, [70, 100], 200, 40) - scoreImage.leftMargin = 50 - scoreImage.rightMargin = 50 + scoreImage = ScoreImage(image, [70, 100]) + scoreImage.areaWidth = 200 + scoreImage.areaHeight = 40 areaFrame = scoreImage.makeFrame(numFrame = 10, among = 30) w,h = areaFrame.size self.assertEqual(w, 200, "") @@ -201,9 +203,6 @@ def setUp(self): height = 16, fps = 30.0, cursorLineColor = (255,0,0), - scrollNotes = False, - leftMargin = 50, - rightMargin = 100, midiResolution = 384, midiTicks = [0, 384, 768, 1152, 1536], temposList = [(0, 60.0)] diff --git a/video.py b/video.py index 6bf6c01..f84cd74 100644 --- a/video.py +++ b/video.py @@ -127,7 +127,6 @@ class VideoFrameWriter(object): """ def __init__(self, width, height, fps, cursorLineColor, - scrollNotes, leftMargin, rightMargin, midiResolution, midiTicks, temposList): """ Params: @@ -155,8 +154,6 @@ def __init__(self, width, height, fps, cursorLineColor, # 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). @@ -169,8 +166,6 @@ def __init__(self, width, height, fps, cursorLineColor, self.midiResolution = midiResolution self.midiTicks = midiTicks self.temposList = temposList - self.leftMargin = leftMargin - self.rightMargin = rightMargin self.runDir = None @@ -186,19 +181,24 @@ def estimateFrames(self): progress("SYNC: ly2video will generate approx. %d frames at %.3f frames/sec." % (estimatedFrames, self.fps)) - def write(self, indices, notesImage): + @property + def scoreImage (self): + return self.__scoreImage + + @scoreImage.setter + def scoreImage (self, scoreImage): + self.__scoreImage = scoreImage + self.__scoreImage.areaWidth = self.width + self.__scoreImage.areaHeight = self.height + self.__scoreImage.cursorLineColor = self.cursorLineColor + + def write(self): """ Params: - indices: indices of notes in pictures - notesImage: filename of the image """ - self.__scoreImage = ScoreImage(Image.open(notesImage), indices, self.width, self.height) - self.__scoreImage.leftMargin = self.leftMargin - self.__scoreImage.rightMargin = self.rightMargin - self.__scoreImage.scrollNotes = self.scrollNotes - self.__scoreImage.cursorLineColor = self.cursorLineColor - # folder to store frames for video if not os.path.exists("notes"): os.mkdir("notes") @@ -366,7 +366,7 @@ def height (self): class ScoreImage (Media): - def __init__ (self, picture, notesXpostions, areaWidth, areaHeight): + def __init__ (self, picture, notesXpostions, leftMargin = 50, rightMargin = 50, scrollNotes = False): Media.__init__(self,picture.size[0], picture.size[1]) self.__picture = picture self.__notesXpositions = notesXpostions @@ -375,16 +375,15 @@ def __init__ (self, picture, notesXpostions, areaWidth, areaHeight): self.__currentNotesIndex = 0 self.__topCroppable = None self.__bottomCroppable = None - self.leftMargin = 50 - self.rightMargin = 50 - self.areaWidth = areaWidth - self.areaHeight = areaHeight + self.leftMargin = leftMargin + self.rightMargin = rightMargin + self.areaWidth = 1920 + self.areaHeight = 1080 self.__leftEdge = None self.__cropTop = None self.__cropBottom = None - self.scrollNotes = False + self.scrollNotes = scrollNotes self.cursorLineColor = (255,0,0) - self.neededFrame = None @property def currentXposition (self): From 698727bcc4007852c0a28b0870f291819f915c38 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Wed, 8 Jan 2014 16:05:59 +0200 Subject: [PATCH 11/29] add --measure-cursor option, following score measure by measure Implements a measure cursor that is slightly less intrusive than the line cursor following each note. Updates writeSpaceTimeDumper() function the measures bars positions. New function getMeasuresIndices(). --- ly2video.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++- test.py | 16 +++++++------- video.py | 29 ++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/ly2video.py b/ly2video.py index e3fc63e..6cb0274 100755 --- a/ly2video.py +++ b/ly2video.py @@ -264,6 +264,40 @@ def getLeftmostGrobsByMoment(output, dpi, leftPaperMarginPx): return groblist +def getMeasuresIndices(output, dpi, leftPaperMarginPx): + ret = [] + ret.append(leftPaperMarginPx) + lines = output.split('\n') + + for line in lines: + if not line.startswith('ly2videoBar: '): + continue + + 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 + + if x not in ret : + ret.append(x) + + ret.sort() + return ret + def findStaffLines(imageFile, lineLength): """ Takes a image and returns y co-ordinates of staff lines in pixels. @@ -802,6 +836,9 @@ def parseOptions(): 'scroll the notation from right to left and keep the ' 'cursor in the centre', action="store_true", default=False) + parser.add_option("--measure-cursor", dest="measureCursor", + help='generate a cursor following the score measure by measure', + action="store_true", default=False) parser.add_option("-t", "--title-at-start", dest="titleAtStart", help='adds title screen at the start of video ' '(with name of song and its author)', @@ -1183,11 +1220,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 @@ -1360,6 +1416,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") @@ -1397,7 +1457,7 @@ def main(): options.width, options.height, fps, getCursorLineColor(options), midiResolution, midiTicks, temposList) leftMargin, rightMargin = options.cursorMargins.split(",") - frameWriter.scoreImage = ScoreImage(Image.open(notesImage), noteIndices, int(leftMargin), int(rightMargin), options.scrollNotes) + frameWriter.scoreImage = ScoreImage(Image.open(notesImage), noteIndices, measuresXpositions, int(leftMargin), int(rightMargin), options.scrollNotes) frameWriter.write() output_divider_line() diff --git a/test.py b/test.py index ca91c5b..09ad94d 100644 --- a/test.py +++ b/test.py @@ -62,7 +62,7 @@ def test__IsLineBlank_withLineAlmostBlank (self): # __setCropTopAndBottom def test__setCropTopAndBottom_withBlackImage(self): - blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), []) + blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], []) blackImage.areaHeight = 16 blackImage._ScoreImage__setCropTopAndBottom() self.assertEqual(blackImage._ScoreImage__cropTop, 0, "Bad cropTop!") @@ -75,7 +75,7 @@ def test__setCropTopAndBottom_withBlackImageTooSmall(self): self.assertEqual(cm.exception.code, 1) def test__setCropTopAndBottom_withBlackImageTooBig(self): - blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], 16, 15) + blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], [], 16, 15) with self.assertRaises(SystemExit) as cm: blackImage._ScoreImage__setCropTopAndBottom() self.assertEqual(cm.exception.code, 1) @@ -83,7 +83,7 @@ def test__setCropTopAndBottom_withBlackImageTooBig(self): def test__setCropTopAndBottom_withBlackPoint(self): image = Image.new("RGB",(16,16),(255,255,255)) image.putpixel((8,8),(0,0,0)) - blackPointImage = ScoreImage(image, []) + blackPointImage = ScoreImage(image, [], []) blackPointImage.areaHeight = 9 blackPointImage._ScoreImage__setCropTopAndBottom() self.assertEqual(blackPointImage._ScoreImage__cropTop, 4, "Bad cropTop!") @@ -93,7 +93,7 @@ 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(Image.new("RGB",(16,16),(0,0,0)), [], 16, 20) + scoreImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], [], 16, 20) with self.assertRaises(SystemExit) as cm: scoreImage._ScoreImage__setCropTopAndBottom() self.assertEqual(cm.exception.code, 1) @@ -102,7 +102,7 @@ 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(Image.new("RGB",(16,16),(0,0,0)), [], 16, 8) + scoreImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], [], 16, 8) with self.assertRaises(SystemExit) as cm: scoreImage._ScoreImage__setCropTopAndBottom() self.assertEqual(cm.exception.code, 1) @@ -112,7 +112,7 @@ 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(image, []) + scoreImage = ScoreImage(image, [], []) scoreImage.areaWidth = 200 scoreImage.areaHeight = 40 index = 70 @@ -126,7 +126,7 @@ 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(image, []) + scoreImage = ScoreImage(image, [], []) scoreImage.areaWidth = 200 scoreImage.areaHeight = 40 index = 200 @@ -188,7 +188,7 @@ 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(image, [70, 100]) + scoreImage = ScoreImage(image, [70, 100], []) scoreImage.areaWidth = 200 scoreImage.areaHeight = 40 areaFrame = scoreImage.makeFrame(numFrame = 10, among = 30) diff --git a/video.py b/video.py index f84cd74..7e54f56 100644 --- a/video.py +++ b/video.py @@ -34,6 +34,16 @@ def writeCursorLine(image, X, color): 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 @@ -350,6 +360,7 @@ def ticksToSecs(self, startTick, endTick): class BlankScoreImageError (Exception): pass + class Media (object): def __init__ (self, width = 1280, height = 720): @@ -366,12 +377,15 @@ def height (self): class ScoreImage (Media): - def __init__ (self, picture, notesXpostions, leftMargin = 50, rightMargin = 50, scrollNotes = False): + def __init__ (self, picture, notesXpostions, measuresXpositions, leftMargin = 50, rightMargin = 50, scrollNotes = False): Media.__init__(self,picture.size[0], picture.size[1]) 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 @@ -395,6 +409,9 @@ def travelToNextNote (self): def moveToNextNote (self): self.__currentNotesIndex += 1 + if self.__measuresXpositions: + if self.currentXposition > self.__measuresXpositions[self.__currentMeasureIndex+1] : + self.__currentMeasureIndex += 1 @property def notesXpostions (self): @@ -508,8 +525,14 @@ def makeFrame (self, numFrame, among): scoreFrame, cursorX = self.__cropFrame(index) - # Cursor - writeCursorLine(scoreFrame, cursorX, self.cursorLineColor) + # 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) + else: + writeCursorLine(scoreFrame, cursorX, self.cursorLineColor) return scoreFrame From 0a60a031c3034b81eea88d00c86df1f747e490ab Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Wed, 8 Jan 2014 16:05:59 +0200 Subject: [PATCH 12/29] unit tests: testing 'writeMeasureCursor' and 'writeCursorLine' --- test.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test.py b/test.py index 09ad94d..c79f5d3 100644 --- a/test.py +++ b/test.py @@ -195,6 +195,29 @@ def testMakeFrame (self): 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): From 5cdffd8ee6a06a92fb82cf9469d9ad3ee30e4d94 Mon Sep 17 00:00:00 2001 From: Mathieu Giraud Date: Wed, 8 Jan 2014 18:14:59 +0200 Subject: [PATCH 13/29] add --note-cursor and --no-cursor options These options are added to be more coherent with --measure-cursor, and to allow no cursor at all. --- ly2video.py | 8 +++++++- video.py | 5 +++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ly2video.py b/ly2video.py index 6cb0274..f9550f4 100755 --- a/ly2video.py +++ b/ly2video.py @@ -836,6 +836,12 @@ def parseOptions(): 'scroll the notation from right to left and keep the ' 'cursor in the centre', action="store_true", default=False) + parser.add_option("--no-cursor", dest="noteCursor", + help='do not generate a cursor', + action="store_false", default=True) + parser.add_option("--note-cursor", dest="noteCursor", + help='generate a cursor following the score note by note (default)', + action="store_true", default=True) parser.add_option("--measure-cursor", dest="measureCursor", help='generate a cursor following the score measure by measure', action="store_true", default=False) @@ -1457,7 +1463,7 @@ def main(): options.width, options.height, fps, getCursorLineColor(options), midiResolution, midiTicks, temposList) leftMargin, rightMargin = options.cursorMargins.split(",") - frameWriter.scoreImage = ScoreImage(Image.open(notesImage), noteIndices, measuresXpositions, int(leftMargin), int(rightMargin), options.scrollNotes) + frameWriter.scoreImage = ScoreImage(Image.open(notesImage), noteIndices, measuresXpositions, int(leftMargin), int(rightMargin), options.scrollNotes,options.noteCursor) frameWriter.write() output_divider_line() diff --git a/video.py b/video.py index 7e54f56..e762f8e 100644 --- a/video.py +++ b/video.py @@ -377,7 +377,7 @@ def height (self): class ScoreImage (Media): - def __init__ (self, picture, notesXpostions, measuresXpositions, leftMargin = 50, rightMargin = 50, scrollNotes = False): + def __init__ (self, picture, notesXpostions, measuresXpositions, leftMargin = 50, rightMargin = 50, scrollNotes = False, noteCursor = True): Media.__init__(self,picture.size[0], picture.size[1]) self.__picture = picture self.__notesXpositions = notesXpostions @@ -396,6 +396,7 @@ def __init__ (self, picture, notesXpostions, measuresXpositions, leftMargin = 50 self.__leftEdge = None self.__cropTop = None self.__cropBottom = None + self.__noteCursor = noteCursor self.scrollNotes = scrollNotes self.cursorLineColor = (255,0,0) @@ -531,7 +532,7 @@ def makeFrame (self, numFrame, among): start = self.__measuresXpositions[self.__currentMeasureIndex] - origin end = self.__measuresXpositions[self.__currentMeasureIndex + 1] - origin writeMeasureCursor(scoreFrame, start, end, self.cursorLineColor) - else: + elif self.__noteCursor: writeCursorLine(scoreFrame, cursorX, self.cursorLineColor) return scoreFrame From b575fc2c56c59a364621015fc916d316dc293cdf Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Thu, 9 Jan 2014 02:18:59 +0200 Subject: [PATCH 14/29] add --slide-show option, displaying fixed pictures in one area of the video VideoFrameWriter is now able to push up medias. --- ly2video.py | 4 ++++ video.py | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/ly2video.py b/ly2video.py index f9550f4..a9a4da1 100755 --- a/ly2video.py +++ b/ly2video.py @@ -798,6 +798,8 @@ def parseOptions(): parser.add_option("-i", "--input", dest="input", help="input LilyPond file", metavar="INPUT-FILE") + parser.add_option("--slide-show", dest="slideShow", + help="input file prefix to generate a slide show") parser.add_option("-o", "--output", dest="output", help='name of output video (e.g. "myNotes.avi") ' '[INPUT-FILE.avi]', @@ -1464,6 +1466,8 @@ def main(): midiResolution, midiTicks, temposList) leftMargin, rightMargin = options.cursorMargins.split(",") frameWriter.scoreImage = ScoreImage(Image.open(notesImage), noteIndices, measuresXpositions, int(leftMargin), int(rightMargin), options.scrollNotes,options.noteCursor) + if options.slideShow : + frameWriter.push(SlideShow(options.slideShow)) frameWriter.write() output_divider_line() diff --git a/video.py b/video.py index e762f8e..4e8f520 100644 --- a/video.py +++ b/video.py @@ -180,6 +180,11 @@ def __init__(self, width, height, fps, cursorLineColor, self.runDir = None self.__scoreImage = None + self.__medias = [] + self.__offset = 0 + + def push (self, media): + self.__medias.append(media) def estimateFrames(self): approxBeats = float(self.midiTicks[-1]) / self.midiResolution @@ -244,6 +249,14 @@ def write(self): ticks = endTick - startTick debug("ticks: %d -> %d (%d)" % (startTick, endTick, ticks)) + # SLIDE SHOW HANDLING + # convert a midi tick event into an offset (1 == quarter) + # The factor between midi tick and offset is 384 + # should be more precise to convert from lilypond moment... + # With old notes indices data structure: + # offset = indices[i][0]*4 + self.__offset = float(startTick)/384.0 + # 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 @@ -337,6 +350,17 @@ def writeVideoFrames(self, neededFrames): w, h = scoreFrame.size frame = Image.new("RGB", (w,h), "white") frame.paste(scoreFrame,(0,0,w,h)) + for media in self.__medias : + mediaFrame = media.makeFrame(numFrame = i, among = neededFrames, offset = self.__offset) + wm, hm = mediaFrame.size + w = max(w,wm) + h += hm + f = Image.new("RGB", (w,h), "white") + f.paste(mediaFrame,(0,0,wm,hm)) + wf, hf = frame.size + f.paste(frame,(0,hm,wf,h)) + frame = f + # Save the frame. ffmpeg doesn't work if the numbers in these # filenames are zero-padded. @@ -360,7 +384,6 @@ def ticksToSecs(self, startTick, endTick): class BlankScoreImageError (Exception): pass - class Media (object): def __init__ (self, width = 1280, height = 720): @@ -518,7 +541,7 @@ def __cropFrame(self,index): rightEdge, self.__cropBottom)) return (frame,cursorX) - def makeFrame (self, numFrame, among): + def makeFrame (self, numFrame, among, offset = None): startIndex = self.currentXposition indexTravel = self.travelToNextNote travelPerFrame = float(indexTravel) / among @@ -584,4 +607,23 @@ def bottomCroppable (self): if self.__bottomCroppable is None: self.__setBottomCroppable() return self.__bottomCroppable + +class SlideShow (Media): + def __init__(self, fileNamePrefix): + self.__fileNamePrefix = fileNamePrefix + self.__fileName = "" + self.__slide = None + self.cursorLineColor = (255,0,0) + + def makeFrame (self, numFrame, among, offset = None): + # We check if the slide must change + newFileName = "%s%09.4f.png" % (self.__fileNamePrefix,offset) + 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() + return tmpSlide + From e778c2aa4050bfe724869e07be7bb8137c1b2a30 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Thu, 9 Jan 2014 02:18:59 +0200 Subject: [PATCH 15/29] --slide-show: more documentation in doc/slideshow.txt --- doc/slideshow.txt | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 doc/slideshow.txt 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. + + + From 1667ef5ccc286fb541db8e5ec01b9e0ca8062a01 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Thu, 9 Jan 2014 02:18:59 +0200 Subject: [PATCH 16/29] --slide-show: add a cursor line at a constant speed This cursor can be used when the slide show displays some structure diagram. Some hard coded values should be set by command line options... --- video.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/video.py b/video.py index 4e8f520..69cb023 100644 --- a/video.py +++ b/video.py @@ -256,6 +256,9 @@ def write(self): # With old notes indices data structure: # offset = indices[i][0]*4 self.__offset = float(startTick)/384.0 + for media in self.__medias: + media.startOffset = float(startTick)/384.0 + media.endOffset = float(endTick)/384.0 # If we have 1+ tempo changes in between adjacent indices, # we need to keep track of how many seconds elapsed since @@ -615,15 +618,26 @@ def __init__(self, fileNamePrefix): self.__fileName = "" self.__slide = None self.cursorLineColor = (255,0,0) + self.__cursorStart = 120.0 # HARD CODED!!! + self.__cursorEnd = 440.0# HARD CODED!!! + self.startOffset = 0.0 + self.endOffset = 0.0 + self.__lastOffset = 40.0 # HARD CODED!!! def makeFrame (self, numFrame, among, offset = None): - # We check if the slide must change - newFileName = "%s%09.4f.png" % (self.__fileNamePrefix,offset) + # We check if the slide must change + start = self.startOffset * ((self.__cursorEnd - self.__cursorStart)/self.__lastOffset) + end = self.endOffset * ((self.__cursorEnd - self.__cursorStart)/self.__lastOffset) + 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 From 64df6b4b961fa28e3a95b7491e556c8acdddf71f Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Thu, 9 Jan 2014 02:18:59 +0200 Subject: [PATCH 17/29] --slide-show: option --slide-show-cursor to set the start/end positions of the cursor --- ly2video.py | 4 +++- video.py | 22 ++++++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/ly2video.py b/ly2video.py index a9a4da1..ef8e45a 100755 --- a/ly2video.py +++ b/ly2video.py @@ -800,6 +800,8 @@ def parseOptions(): help="input LilyPond file", metavar="INPUT-FILE") parser.add_option("--slide-show", dest="slideShow", help="input file prefix to generate a slide show") + parser.add_option("--slide-show-cursor", dest="slideShowCursor", type="float", + help="start and end positions on the cursor in the slide show",nargs=2) parser.add_option("-o", "--output", dest="output", help='name of output video (e.g. "myNotes.avi") ' '[INPUT-FILE.avi]', @@ -1467,7 +1469,7 @@ def main(): leftMargin, rightMargin = options.cursorMargins.split(",") frameWriter.scoreImage = ScoreImage(Image.open(notesImage), noteIndices, measuresXpositions, int(leftMargin), int(rightMargin), options.scrollNotes,options.noteCursor) if options.slideShow : - frameWriter.push(SlideShow(options.slideShow)) + frameWriter.push(SlideShow(options.slideShow,options.slideShowCursor,midiTicks[-1]/384.0)) frameWriter.write() output_divider_line() diff --git a/video.py b/video.py index 69cb023..7d4629e 100644 --- a/video.py +++ b/video.py @@ -613,16 +613,26 @@ def bottomCroppable (self): class SlideShow (Media): - def __init__(self, fileNamePrefix): + def __init__(self, fileNamePrefix, cursorPos = None, lastOffset = None): self.__fileNamePrefix = fileNamePrefix - self.__fileName = "" - self.__slide = None + 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) - self.__cursorStart = 120.0 # HARD CODED!!! - self.__cursorEnd = 440.0# HARD CODED!!! + + # 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 - self.__lastOffset = 40.0 # HARD CODED!!! def makeFrame (self, numFrame, among, offset = None): # We check if the slide must change From 9eeabbc02585042872f4f8c1d511455ac767d821 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 14 Jan 2014 18:14:59 +0200 Subject: [PATCH 18/29] adds height and width in ScoreImage constructor Deletes height and width in VideoFrameWriter constructor (No height limit, score) --- ly2video.py | 6 ++-- test.py | 47 +++++++++++++---------------- video.py | 86 ++++++++++++++++++++++++++--------------------------- 3 files changed, 65 insertions(+), 74 deletions(-) diff --git a/ly2video.py b/ly2video.py index ef8e45a..24ee4d6 100755 --- a/ly2video.py +++ b/ly2video.py @@ -1463,11 +1463,9 @@ def main(): fps = options.fps # generate notes - frameWriter = VideoFrameWriter( - options.width, options.height, fps, getCursorLineColor(options), - midiResolution, midiTicks, temposList) + frameWriter = VideoFrameWriter(fps, getCursorLineColor(options), midiResolution, midiTicks, temposList) leftMargin, rightMargin = options.cursorMargins.split(",") - frameWriter.scoreImage = ScoreImage(Image.open(notesImage), noteIndices, measuresXpositions, int(leftMargin), int(rightMargin), options.scrollNotes,options.noteCursor) + frameWriter.scoreImage = ScoreImage(options.width, options.height, Image.open(notesImage), noteIndices, measuresXpositions, int(leftMargin), int(rightMargin), options.scrollNotes, options.noteCursor) if options.slideShow : frameWriter.push(SlideShow(options.slideShow,options.slideShowCursor,midiTicks[-1]/384.0)) frameWriter.write() diff --git a/test.py b/test.py index c79f5d3..11781b4 100644 --- a/test.py +++ b/test.py @@ -30,11 +30,11 @@ class ScoreImageTest (unittest.TestCase): def setUp(self): image = Image.new("RGB",(1000,200),(255,255,255)) - self.blankImage = ScoreImage(image, [], 1000,200) + 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(image, [], 1000,200) + self.pointsImage = ScoreImage(1000,200,image, [], []) # PRIVATE METHODS # __isLineBlank @@ -62,20 +62,19 @@ def test__IsLineBlank_withLineAlmostBlank (self): # __setCropTopAndBottom def test__setCropTopAndBottom_withBlackImage(self): - blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], []) - blackImage.areaHeight = 16 + 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(Image.new("RGB",(16,16),(0,0,0)), [], 16, 17) + 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(Image.new("RGB",(16,16),(0,0,0)), [], [], 16, 15) + 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) @@ -83,8 +82,8 @@ def test__setCropTopAndBottom_withBlackImageTooBig(self): def test__setCropTopAndBottom_withBlackPoint(self): image = Image.new("RGB",(16,16),(255,255,255)) image.putpixel((8,8),(0,0,0)) - blackPointImage = ScoreImage(image, [], []) - blackPointImage.areaHeight = 9 + 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!") @@ -93,7 +92,7 @@ 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(Image.new("RGB",(16,16),(0,0,0)), [], [], 16, 20) + 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) @@ -102,7 +101,7 @@ 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(Image.new("RGB",(16,16),(0,0,0)), [], [], 16, 8) + 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) @@ -112,9 +111,7 @@ 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(image, [], []) - scoreImage.areaWidth = 200 - scoreImage.areaHeight = 40 + scoreImage = ScoreImage(200,40,image, [], []) index = 70 areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index) w,h = areaFrame.size @@ -126,9 +123,7 @@ 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(image, [], []) - scoreImage.areaWidth = 200 - scoreImage.areaHeight = 40 + scoreImage = ScoreImage(200,40,image, [], []) index = 200 areaFrame, cursorX = scoreImage._ScoreImage__cropFrame(index) w,h = areaFrame.size @@ -139,48 +134,48 @@ def test__cropFrame_withIndexHigherThanWidth (self): # PUBLIC METHODS # topCroppable def testTopCroppable_withBlackImage (self): - blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], 16, 16) + 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(Image.new("RGB",(16,16),(255,255,255)), [], 16, 16) + 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(image, [], 16, 16) + 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(image, [], 16, 16) + blackImage = ScoreImage(16,16,image, [], []) self.assertEqual(blackImage.topCroppable, 8, "Bad topMarginSize") # bottomCroppable def testBottomCroppable_withBlackImage (self): - blackImage = ScoreImage(Image.new("RGB",(16,16),(0,0,0)), [], 16, 16) + 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(Image.new("RGB",(16,16),(255,255,255)), [], 16, 16) + 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(image, [], 16, 16) + 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(image, [], 16, 16) + blackImage = ScoreImage(16, 16, image, [], []) self.assertEqual(blackImage.bottomCroppable, 7, "Bad topMarginSize") # makeFrame @@ -188,7 +183,7 @@ 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(image, [70, 100], []) + scoreImage = ScoreImage(200, 40, image, [70, 100], []) scoreImage.areaWidth = 200 scoreImage.areaHeight = 40 areaFrame = scoreImage.makeFrame(numFrame = 10, among = 30) @@ -222,8 +217,6 @@ class VideoFrameWriterTest(unittest.TestCase): def setUp(self): self.frameWriter = VideoFrameWriter( - width = 16, - height = 16, fps = 30.0, cursorLineColor = (255,0,0), midiResolution = 384, diff --git a/video.py b/video.py index 7d4629e..abf4175 100644 --- a/video.py +++ b/video.py @@ -136,7 +136,7 @@ class VideoFrameWriter(object): skips generating one frame. """ - def __init__(self, width, height, fps, cursorLineColor, + def __init__(self, fps, cursorLineColor, midiResolution, midiTicks, temposList): """ Params: @@ -169,8 +169,8 @@ def __init__(self, width, height, fps, cursorLineColor, # left edge of the cropping rectangle). self.leftEdge = None - self.width = width - self.height = height + self.width = None + self.height = None self.fps = fps self.cursorLineColor = cursorLineColor self.midiResolution = midiResolution @@ -181,10 +181,10 @@ def __init__(self, width, height, fps, cursorLineColor, self.__scoreImage = None self.__medias = [] - self.__offset = 0 def push (self, media): - self.__medias.append(media) + self.height += media.height + self.__medias.append(media) def estimateFrames(self): approxBeats = float(self.midiTicks[-1]) / self.midiResolution @@ -202,10 +202,11 @@ def scoreImage (self): @scoreImage.setter def scoreImage (self, scoreImage): + self.width = scoreImage.width + self.height = scoreImage.height self.__scoreImage = scoreImage - self.__scoreImage.areaWidth = self.width - self.__scoreImage.areaHeight = self.height self.__scoreImage.cursorLineColor = self.cursorLineColor + def write(self): """ @@ -255,7 +256,6 @@ def write(self): # should be more precise to convert from lilypond moment... # With old notes indices data structure: # offset = indices[i][0]*4 - self.__offset = float(startTick)/384.0 for media in self.__medias: media.startOffset = float(startTick)/384.0 media.endOffset = float(endTick)/384.0 @@ -349,25 +349,21 @@ def writeVideoFrames(self, neededFrames): for i in xrange(neededFrames): debug(" writing frame %d" % (self.frameNum)) + videoFrame = Image.new("RGB", (self.width,self.height), "white") scoreFrame = self.__scoreImage.makeFrame(numFrame = i, among = neededFrames) w, h = scoreFrame.size - frame = Image.new("RGB", (w,h), "white") - frame.paste(scoreFrame,(0,0,w,h)) + videoFrame.paste(scoreFrame, (0,self.height-h,w,self.height)) for media in self.__medias : - mediaFrame = media.makeFrame(numFrame = i, among = neededFrames, offset = self.__offset) + mediaFrame = media.makeFrame(numFrame = i, among = neededFrames) wm, hm = mediaFrame.size w = max(w,wm) h += hm - f = Image.new("RGB", (w,h), "white") - f.paste(mediaFrame,(0,0,wm,hm)) - wf, hf = frame.size - f.paste(frame,(0,hm,wf,h)) - frame = f - - + videoFrame.paste(mediaFrame, (0,self.height-h,wm,self.height-h+hm)) + + #del draw # 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)) + videoFrame.save(tmpPath("notes", "frame%d.png" % self.frameNum)) self.frameNum += 1 if not DEBUG and self.frameNum % 10 == 0: sys.stdout.write(".") @@ -403,8 +399,8 @@ def height (self): class ScoreImage (Media): - def __init__ (self, picture, notesXpostions, measuresXpositions, leftMargin = 50, rightMargin = 50, scrollNotes = False, noteCursor = True): - Media.__init__(self,picture.size[0], picture.size[1]) + def __init__ (self, width, height, picture, notesXpostions, measuresXpositions, leftMargin = 50, rightMargin = 50, scrollNotes = False, noteCursor = True): + Media.__init__(self, width, height) self.__picture = picture self.__notesXpositions = notesXpostions if len(self.__notesXpositions) > 0 : @@ -417,8 +413,6 @@ def __init__ (self, picture, notesXpostions, measuresXpositions, leftMargin = 50 self.__bottomCroppable = None self.leftMargin = leftMargin self.rightMargin = rightMargin - self.areaWidth = 1920 - self.areaHeight = 1080 self.__leftEdge = None self.__cropTop = None self.__cropBottom = None @@ -455,14 +449,15 @@ def __setCropTopAndBottom(self): (non-cropped) image. """ if self.__cropTop is not None and self.__cropBottom is not None: return + picture_width, picture_height = self.__picture.size - bottomY = self.height - self.bottomCroppable - progress(" Image height: %5d pixels" % self.height) + 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 = self.height - self.topCroppable - self.bottomCroppable + nonWhiteRows = picture_height - self.topCroppable - self.bottomCroppable progress("Visible content is formed of %d non-white rows of pixels" % nonWhiteRows) @@ -474,8 +469,8 @@ def __setCropTopAndBottom(self): # Now choose top/bottom cropping coordinates which center # the content in the video frame. - self.__cropTop = nonWhiteCentre - int(round(self.areaHeight / 2)) - self.__cropBottom = self.__cropTop + self.areaHeight + 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. @@ -491,13 +486,13 @@ def __setCropTopAndBottom(self): (-self.__cropTop, maxHeight)) self.__cropTop = 0 - if self.__cropBottom > self.height: + if self.__cropBottom > picture_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" % - (self.__cropBottom - self.height, maxHeight)) - self.__cropBottom = self.height + (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! " @@ -517,6 +512,8 @@ def __setCropTopAndBottom(self): 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 @@ -533,18 +530,20 @@ def __cropFrame(self,index): cursorX = index - self.__leftEdge debug(" left edge at %d, cursor at %d" % (self.__leftEdge, cursorX)) - if cursorX > self.areaWidth - self.rightMargin: +# if cursorX > self.areaWidth - self.rightMargin: + 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.areaWidth +# rightEdge = self.__leftEdge + self.areaWidth + 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, offset = None): + def makeFrame (self, numFrame, among): startIndex = self.currentXposition indexTravel = self.travelToNextNote travelPerFrame = float(indexTravel) / among @@ -575,26 +574,28 @@ def __isLineBlank(self, pixels, width, y): 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(self.height): - if y == self.height - 1: + for y in xrange(picture_height): + if y == picture_height - 1: raise BlankScoreImageError - if self.__isLineBlank(pixels, self.width, y): + 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(self.height - 1, -1, -1): + for y in xrange(picture_height - 1, -1, -1): if y == 0: raise BlankScoreImageError - if self.__isLineBlank(pixels, self.width, y): + if self.__isLineBlank(pixels, picture_width, y): self.__bottomCroppable += 1 else: break @@ -618,7 +619,6 @@ def __init__(self, fileNamePrefix, cursorPos = None, lastOffset = None): 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 @@ -634,10 +634,10 @@ def __init__(self, fileNamePrefix, cursorPos = None, lastOffset = None): self.startOffset = 0.0 self.endOffset = 0.0 - def makeFrame (self, numFrame, among, offset = None): + def makeFrame (self, numFrame, among): # We check if the slide must change - start = self.startOffset * ((self.__cursorEnd - self.__cursorStart)/self.__lastOffset) - end = self.endOffset * ((self.__cursorEnd - self.__cursorStart)/self.__lastOffset) + start = self.startOffset * self.__scale + end = self.endOffset * self.__scale travelPerFrame = float(end - start) / among index = start + int(round(numFrame * travelPerFrame)) + self.__cursorStart From 7c53874b78c93876cc7ca2c026a7a5e7192f067f Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 14 Jan 2014 18:14:59 +0200 Subject: [PATCH 19/29] unit tests: 'VideoFrameWriter' testing Tested classes: * VideoFrameWriter: . push() . scoreImageSetter() --- test.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test.py b/test.py index 11781b4..4266a98 100644 --- a/test.py +++ b/test.py @@ -227,6 +227,23 @@ def setUp(self): for x in range(16) : self.image.putpixel((x,8),(0,0,0)) + def testPush (self): + frameWriter = VideoFrameWriter(30.0,(255,0,0),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),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 testWrite (self): + pass + + def testWriteVideoFrames (self): + pass def testTicksToSecs (self): for tempo in range (1,300): From 06d6b122f11ab2d853777b4622b8f15a129b3483 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 14 Jan 2014 18:14:59 +0200 Subject: [PATCH 20/29] improve output: the end of the score is right-aligned at the end of the video --- video.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/video.py b/video.py index abf4175..518b533 100644 --- a/video.py +++ b/video.py @@ -530,14 +530,15 @@ def __cropFrame(self,index): cursorX = index - self.__leftEdge debug(" left edge at %d, cursor at %d" % (self.__leftEdge, cursorX)) -# if cursorX > self.areaWidth - self.rightMargin: 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.areaWidth + 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)) From b484b8104b02de40e199a8fff2eb84ac7249015f Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Wed, 5 Feb 2014 00:42:59 +0200 Subject: [PATCH 21/29] refactor: new 'synchro.TimeCode' class to drive medias Time handling methods are moved to this class. The design pattern 'Observer' is used. New 'synchro' module. --- synchro.py | 192 +++++++++++++++++++++++++++++++++++++++++++ utils.py | 17 ++++ video.py | 234 ++++++++++++----------------------------------------- 3 files changed, 260 insertions(+), 183 deletions(-) create mode 100644 synchro.py diff --git a/synchro.py b/synchro.py new file mode 100644 index 0000000..d318444 --- /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)/384.0 + self.nextOffset = float(self.__nextTick)/384.0 + + + 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)/384.0 + self.nextOffset = float(self.__nextTick)/384.0 + 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/utils.py b/utils.py index 4dca7ff..c3d3fb4 100644 --- a/utils.py +++ b/utils.py @@ -93,3 +93,20 @@ def tmpPath(*dirs): 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 index 518b533..e6c07fd 100644 --- a/video.py +++ b/video.py @@ -22,6 +22,7 @@ # For more information about this program, please visit # . +from synchro import * from utils import * import os from PIL import Image @@ -156,13 +157,11 @@ def __init__(self, fps, cursorLineColor, - 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.secs = 0.0 # In cursor scrolling mode, this is the x-coordinate in the # original image of the left edge of the frame (i.e. the @@ -173,28 +172,20 @@ def __init__(self, fps, cursorLineColor, self.height = None self.fps = fps self.cursorLineColor = cursorLineColor - self.midiResolution = midiResolution - self.midiTicks = midiTicks - self.temposList = temposList + #self.midiResolution = midiResolution + #self.midiTicks = midiTicks + #self.temposList = temposList 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) - - 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)) + self.__timecode.registerObserver(media) @property def scoreImage (self): @@ -206,180 +197,44 @@ def scoreImage (self, scoreImage): self.height = scoreImage.height self.__scoreImage = scoreImage self.__scoreImage.cursorLineColor = self.cursorLineColor - + self.__timecode.registerObserver(scoreImage) - def write(self): - """ - Params: - - indices: indices of notes in pictures - - notesImage: filename of the image - """ - + def write (self): # 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]) - - 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) - - # generate all frames in between each pair of adjacent indices - while self.midiIndex < len(self.midiTicks) - 1: -# 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)) - - # SLIDE SHOW HANDLING - # convert a midi tick event into an offset (1 == quarter) - # The factor between midi tick and offset is 384 - # should be more precise to convert from lilypond moment... - # With old notes indices data structure: - # offset = indices[i][0]*4 - for media in self.__medias: - media.startOffset = float(startTick)/384.0 - media.endOffset = float(endTick)/384.0 - - # 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) - - # 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) - - # Update time in the *ideal* (i.e. not real) world - this - # is totally independent of fps. - self.secs = targetSecs - self.__scoreImage.moveToNextNote() - print - - progress("SYNC: Generated %d frames" % self.frameNum) - - 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 + 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.image.size + videoFrame.paste(scoreFrame.image,(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 - 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 writeVideoFrames(self, neededFrames): - """ - Writes the required number of frames to travel indexTravel - pixels from startIndex, incrementing frameNum for each frame - written. - """ - for i in xrange(neededFrames): - debug(" writing frame %d" % (self.frameNum)) - - videoFrame = Image.new("RGB", (self.width,self.height), "white") - scoreFrame = self.__scoreImage.makeFrame(numFrame = i, among = neededFrames) - w, h = scoreFrame.size - videoFrame.paste(scoreFrame, (0,self.height-h,w,self.height)) - for media in self.__medias : - mediaFrame = media.makeFrame(numFrame = i, among = neededFrames) - wm, hm = mediaFrame.size - w = max(w,wm) - h += hm - videoFrame.paste(mediaFrame, (0,self.height-h,wm,self.height-h+hm)) - - #del draw - # 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() - - 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 - class BlankScoreImageError (Exception): pass @@ -396,6 +251,12 @@ def width (self): @property def height (self): return self.__height + + def makeFrame (self, numframe, among): + pass + + def update (self, timecode): + pass class ScoreImage (Media): @@ -612,6 +473,9 @@ def bottomCroppable (self): if self.__bottomCroppable is None: self.__setBottomCroppable() return self.__bottomCroppable + + def update (self, timecode): + self.moveToNextNote() class SlideShow (Media): @@ -651,4 +515,8 @@ def makeFrame (self, numFrame, among): tmpSlide = self.__slide.copy() writeCursorLine(tmpSlide, int(index), self.cursorLineColor) return tmpSlide + + def update(self, timecode): + self.startOffset = timecode.currentOffset + self.endOffset = timecode.nextOffset From 8a9880fb35404bf51dc735488304a1e2e98628f5 Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Wed, 5 Feb 2014 00:42:59 +0200 Subject: [PATCH 22/29] unit tests: 'TimeCode' class testing Add tests for the new class TimeCode, update some tests. Some new fonctional tests (testCompleteFollowing) Tested classes: * TimeCode: . ticksToSecs() . secsElapsedForTempoChanges() . nbFramesToNextNote() . goToNextNote() . atEnd() * ScoreImage: . currentXposition . moveToNextNote() . cropFrame() --- test.py | 218 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 180 insertions(+), 38 deletions(-) diff --git a/test.py b/test.py index 4266a98..465e73c 100644 --- a/test.py +++ b/test.py @@ -24,8 +24,128 @@ 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): @@ -131,6 +251,63 @@ def test__cropFrame_withIndexHigherThanWidth (self): 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): @@ -212,7 +389,7 @@ def testWriteMeasureCursor (self): 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): @@ -228,52 +405,17 @@ def setUp(self): def testPush (self): - frameWriter = VideoFrameWriter(30.0,(255,0,0),0,[],[]) + 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),0,[],[]) + 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 testWrite (self): - pass - - def testWriteVideoFrames (self): - pass - - def testTicksToSecs (self): - for tempo in range (1,300): - self.frameWriter.tempo = float(tempo) - secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 0) - self.assertEqual(secsSinceStartIndex, 0.0, "") - self.frameWriter.tempo = 60.0 - secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 384) - self.assertEqual(secsSinceStartIndex, 1.0, "") - secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 768) - self.assertEqual(secsSinceStartIndex, 2.0, "") - self.frameWriter.tempo = 90.0 - secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 1152) - self.assertEqual(secsSinceStartIndex, 2.0, "") - secsSinceStartIndex = self.frameWriter.ticksToSecs(0, 3456) - self.assertEqual(secsSinceStartIndex, 6.0, "") - - def testSecsElapsedForTempoChanges (self): - self.frameWriter.temposList = [(0, 60.0),(1152, 90.0),(3456, 60.0)] - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 1152) - self.assertEqual(result,3.0,"") - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 3456) - self.assertEqual(result,6.0,"") - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 1152, endTick = 3456) - self.assertEqual(result,4.0,"") - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 3456, endTick = 4608) - self.assertEqual(result,3.0,"") - result = self.frameWriter.secsElapsedForTempoChanges(startTick = 0, endTick = 4608) - self.assertEqual(result,12.0,"") - 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)) From 9e22d49541404b13a408fe1708703f146ac73b7e Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Wed, 5 Feb 2014 00:42:59 +0200 Subject: [PATCH 23/29] video.py: add comments describing the classes --- video.py | 53 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/video.py b/video.py index e6c07fd..8767958 100644 --- a/video.py +++ b/video.py @@ -135,34 +135,24 @@ class VideoFrameWriter(object): 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: - - width: pixel width of frames (and video) - - height: pixel height of frames (and video) + - videoDef: Strict definition of the final 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.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 - # 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). @@ -172,9 +162,6 @@ def __init__(self, fps, cursorLineColor, self.height = None self.fps = fps self.cursorLineColor = cursorLineColor - #self.midiResolution = midiResolution - #self.midiTicks = midiTicks - #self.temposList = temposList self.runDir = None @@ -240,6 +227,11 @@ class BlankScoreImageError (Exception): 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 @@ -259,8 +251,30 @@ 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 @@ -479,6 +493,11 @@ def update (self, timecode): 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) From cdcc954a0db614b7fb54cf122900fcc97d118a76 Mon Sep 17 00:00:00 2001 From: Mathieu Giraud Date: Mon, 3 Mar 2014 11:32:59 +0200 Subject: [PATCH 24/29] video.py: add options hints (-r/-y) to error messages related to video height --- video.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/video.py b/video.py index 8767958..3de8f21 100644 --- a/video.py +++ b/video.py @@ -355,31 +355,31 @@ def __setCropTopAndBottom(self): if self.__cropTop < 0: fatal("Would have to crop %d pixels above top of image! " - "Try increasing the resolution DPI " + "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" % + "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 " + "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" % + "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, " - "or decreasing the resolution DPI." + "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, " - "or decreasing the resolution DPI." + "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 From 937e3f04794e1362f4f949e9dd73e87cb352d74f Mon Sep 17 00:00:00 2001 From: Emmanuel Leguy Date: Tue, 11 Mar 2014 13:26:59 +0200 Subject: [PATCH 25/29] uses midiResolution instead of 384 whenever possible --- ly2video.py | 3 ++- synchro.py | 8 ++++---- video.py | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ly2video.py b/ly2video.py index 24ee4d6..37ce473 100755 --- a/ly2video.py +++ b/ly2video.py @@ -1467,7 +1467,8 @@ def main(): leftMargin, rightMargin = options.cursorMargins.split(",") frameWriter.scoreImage = ScoreImage(options.width, options.height, Image.open(notesImage), noteIndices, measuresXpositions, int(leftMargin), int(rightMargin), options.scrollNotes, options.noteCursor) if options.slideShow : - frameWriter.push(SlideShow(options.slideShow,options.slideShowCursor,midiTicks[-1]/384.0)) + lastOffset = midiTicks[-1]/midiResolution + frameWriter.push(SlideShow(options.slideShow,options.slideShowCursor,lastOffset)) frameWriter.write() output_divider_line() diff --git a/synchro.py b/synchro.py index d318444..b31d4b9 100644 --- a/synchro.py +++ b/synchro.py @@ -77,8 +77,8 @@ def __init__(self, miditicks, temposList, midiResolution, fps): self.__currentTick = self.__miditicks[self.__currentTickIndex] self.__nextTick = self.__miditicks[self.__currentTickIndex + 1] - self.currentOffset = float(self.__currentTick)/384.0 - self.nextOffset = float(self.__nextTick)/384.0 + self.currentOffset = float(self.__currentTick)/self.midiResolution + self.nextOffset = float(self.__nextTick)/self.midiResolution def atEnd(self): @@ -88,8 +88,8 @@ def goToNextNote (self): self.__currentTickIndex += 1 self.__currentTick = self.__miditicks[self.__currentTickIndex] self.__nextTick = self.__miditicks[self.__currentTickIndex+1] - self.currentOffset = float(self.__currentTick)/384.0 - self.nextOffset = float(self.__nextTick)/384.0 + 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)) diff --git a/video.py b/video.py index 3de8f21..abdb837 100644 --- a/video.py +++ b/video.py @@ -211,8 +211,8 @@ def __makeFrame (self, numFrame, among): videoFrame = Image.new("RGB", (self.width,self.height), "white") scoreFrame = self.__scoreImage.makeFrame(numFrame, among) - w, h = scoreFrame.image.size - videoFrame.paste(scoreFrame.image,(0,self.height-h,w,self.height)) + 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 From 5850f9b09491b57a4e6fafd33853a587f445cc74 Mon Sep 17 00:00:00 2001 From: Mathieu Giraud Date: Tue, 11 Mar 2014 15:15:59 +0200 Subject: [PATCH 26/29] ly2video.py: use argparse to parse command-line As of Python 2.7, optparse is deprecated in favour of argparse. --- ly2video.py | 80 ++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/ly2video.py b/ly2video.py index 37ce473..4070d12 100755 --- a/ly2video.py +++ b/ly2video.py @@ -37,7 +37,7 @@ 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 @@ -794,86 +794,86 @@ def generateSilence(name, length): return out def parseOptions(): - parser = OptionParser("usage: %prog [options]") + parser = ArgumentParser(prog=sys.argv[0]) - parser.add_option("-i", "--input", dest="input", + parser.add_argument("-i", "--input", required=True, help="input LilyPond file", metavar="INPUT-FILE") - parser.add_option("--slide-show", dest="slideShow", - help="input file prefix to generate a slide show") - parser.add_option("--slide-show-cursor", dest="slideShowCursor", type="float", + parser.add_argument("--slide-show", dest="slideShow", + help="input file prefix to genarate a slide show") + parser.add_argument("--slide-show-cursor", dest="slideShowCursor", type=float, help="start and end positions on the cursor in the slide show",nargs=2) - parser.add_option("-o", "--output", dest="output", + parser.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", + parser.add_argument("-b", "--beatmap", help='name of beatmap file for adjusting MIDI tempo', metavar="FILE") - parser.add_option("-c", "--color", dest="color", + parser.add_argument("-c", "--color", help='name of color of middle bar [red]', metavar="COLOR", default="red") - parser.add_option("-f", "--fps", dest="fps", + parser.add_argument("-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", + type=float, metavar="FPS", default=30.0) + parser.add_argument("-q", "--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", + type=int, metavar="N", default=10) + parser.add_argument("-r", "--resolution", dest="dpi", help='resolution in DPI [110]', - metavar="DPI", type="int", default=110) - parser.add_option("-x", "--width", dest="width", + metavar="DPI", type=int, default=110) + parser.add_argument("-x", "--width", help='pixel width of final video [1280]', - metavar="WIDTH", type="int", default=1280) - parser.add_option("-y", "--height", dest="height", + metavar="WIDTH", type=int, default=1280) + parser.add_argument("-y", "--height", help='pixel height of final video [720]', - metavar="HEIGHT", type="int", default=720) - parser.add_option("-m", "--cursor-margins", dest="cursorMargins", + metavar="HEIGHT", type=int, default=720) + parser.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", + metavar="WIDTH,WIDTH", default='50,100') + parser.add_argument("-p", "--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", + metavar="SECS,SECS", default='1,1') + parser.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("--no-cursor", dest="noteCursor", + parser.add_argument("--no-cursor", dest="noteCursor", help='do not generate a cursor', action="store_false", default=True) - parser.add_option("--note-cursor", dest="noteCursor", + parser.add_argument("--note-cursor", dest="noteCursor", help='generate a cursor following the score note by note (default)', action="store_true", default=True) - parser.add_option("--measure-cursor", dest="measureCursor", + parser.add_argument("--measure-cursor", dest="measureCursor", help='generate a cursor following the score measure by measure', action="store_true", default=False) - parser.add_option("-t", "--title-at-start", dest="titleAtStart", + parser.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", + parser.add_argument("--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", + type=int, metavar="SECONDS", default=3) + parser.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") + parser.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", + parser.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", + parser.add_argument("-d", "--debug", help="enable debugging mode", action="store_true", default=False) - parser.add_option("-k", "--keep", dest="keepTempFiles", + parser.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", + parser.add_argument("-v", "--version", dest="showVersion", help="show program version", action="store_true", default=False) @@ -881,7 +881,7 @@ def parseOptions(): parser.print_help() sys.exit(0) - options, args = parser.parse_args() + options = parser.parse_args() if options.showVersion: showVersion() @@ -892,7 +892,7 @@ def parseOptions(): if options.debug: setDebug() - return options, args + return options def getVersion(): try: @@ -1387,7 +1387,7 @@ def main(): - create a video file from the individual frames """ - (options, args) = parseOptions() + options = parseOptions() lilypondVersion, ffmpeg, timidity = findExecutableDependencies(options) From b2206c0107abb837603a0f88a4ab444836f4ac1e Mon Sep 17 00:00:00 2001 From: Mathieu Giraud Date: Tue, 11 Mar 2014 15:20:59 +0200 Subject: [PATCH 27/29] argparse: use %(default)s in option help messages --- ly2video.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ly2video.py b/ly2video.py index 4070d12..bfd23fc 100755 --- a/ly2video.py +++ b/ly2video.py @@ -810,30 +810,30 @@ def parseOptions(): help='name of beatmap file for adjusting MIDI tempo', metavar="FILE") parser.add_argument("-c", "--color", - help='name of color of middle bar [red]', + help='name of color of middle bar [%(default)s]', metavar="COLOR", default="red") parser.add_argument("-f", "--fps", dest="fps", - help='frame rate of final video [30]', + help='frame rate of final video [%(default)s]', type=float, metavar="FPS", default=30.0) parser.add_argument("-q", "--quality", help="video encoding quality as used by ffmpeg's -q option " - '(1 is best, 31 is worst) [10]', + '(1 is best, 31 is worst) [%(default)s]', type=int, metavar="N", default=10) parser.add_argument("-r", "--resolution", dest="dpi", - help='resolution in DPI [110]', + help='resolution in DPI [%(default)s]', metavar="DPI", type=int, default=110) parser.add_argument("-x", "--width", - help='pixel width of final video [1280]', + help='pixel width of final video [%(default)s]', metavar="WIDTH", type=int, default=1280) parser.add_argument("-y", "--height", - help='pixel height of final video [720]', + help='pixel height of final video [%(default)s]', metavar="HEIGHT", type=int, default=720) parser.add_argument("-m", "--cursor-margins", dest="cursorMargins", help='width of left/right margins for scrolling ' - 'in pixels [50,100]', + 'in pixels [%(default)s]', metavar="WIDTH,WIDTH", default='50,100') parser.add_argument("-p", "--padding", - help='time to pause on initial and final frames [1,1]', + help='time to pause on initial and final frames [%(default)s]', metavar="SECS,SECS", default='1,1') parser.add_argument("-s", "--scroll-notes", dest="scrollNotes", help='rather than scrolling the cursor from left to right, ' @@ -854,7 +854,7 @@ def parseOptions(): '(with name of song and its author)', action="store_true", default=False) parser.add_argument("--title-duration", dest="titleDuration", - help='time to display the title screen [3]', + help='time to display the title screen [%(default)s]', type=int, metavar="SECONDS", default=3) parser.add_argument("--ttf", "--title-ttf", dest="titleTtfFile", help='path to TTF font file to use in title', From 8833d0e757b4b2d292eca990367396af4044d95f Mon Sep 17 00:00:00 2001 From: Mathieu Giraud Date: Tue, 11 Mar 2014 15:21:59 +0200 Subject: [PATCH 28/29] argparse: reorder options into groups --- ly2video.py | 111 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 70 insertions(+), 41 deletions(-) diff --git a/ly2video.py b/ly2video.py index bfd23fc..d608f6c 100755 --- a/ly2video.py +++ b/ly2video.py @@ -796,84 +796,113 @@ def generateSilence(name, length): def parseOptions(): parser = ArgumentParser(prog=sys.argv[0]) - parser.add_argument("-i", "--input", required=True, - help="input LilyPond file", metavar="INPUT-FILE") - parser.add_argument("--slide-show", dest="slideShow", - help="input file prefix to genarate a slide show") - parser.add_argument("--slide-show-cursor", dest="slideShowCursor", type=float, - help="start and end positions on the cursor in the slide show",nargs=2) - parser.add_argument("-o", "--output", + group_inout = parser.add_argument_group(title='Input/output files') + + group_inout.add_argument("-i", "--input", required=True, + help="input LilyPond file", metavar="INPUT-FILE") + + group_inout.add_argument("-b", "--beatmap", + help='name of beatmap file for adjusting MIDI tempo', + metavar="FILE") + + group_inout.add_argument("--slide-show", dest="slideShow", + help="input file prefix to generate a slide show (see doc/slideshow.txt)", + metavar="SLIDESHOW-PREFIX") + + group_inout.add_argument("-o", "--output", help='name of output video (e.g. "myNotes.avi") ' '[INPUT-FILE.avi]', metavar="OUTPUT-FILE") - parser.add_argument("-b", "--beatmap", - help='name of beatmap file for adjusting MIDI tempo', - metavar="FILE") - parser.add_argument("-c", "--color", - help='name of color of middle bar [%(default)s]', - metavar="COLOR", default="red") - parser.add_argument("-f", "--fps", dest="fps", + + 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 [%(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) + + 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) - parser.add_argument("-q", "--quality", + 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) - parser.add_argument("-r", "--resolution", dest="dpi", + group_video.add_argument("-r", "--resolution",dest="dpi", help='resolution in DPI [%(default)s]', metavar="DPI", type=int, default=110) - parser.add_argument("-x", "--width", + 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) - parser.add_argument("-y", "--height", + group_video.add_argument("-y", "--height", help='pixel height of final video [%(default)s]', metavar="HEIGHT", type=int, default=720) - parser.add_argument("-m", "--cursor-margins", dest="cursorMargins", - help='width of left/right margins for scrolling ' - 'in pixels [%(default)s]', - metavar="WIDTH,WIDTH", default='50,100') - parser.add_argument("-p", "--padding", - help='time to pause on initial and final frames [%(default)s]', - metavar="SECS,SECS", default='1,1') - parser.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_argument("--no-cursor", dest="noteCursor", + + group_cursors = parser.add_argument_group(title='Cursors') + + group_cursors.add_argument("-c", "--color", + help='name of color of middle bar [%(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) - parser.add_argument("--note-cursor", dest="noteCursor", + group_cursors.add_argument("--note-cursor", dest="noteCursor", help='generate a cursor following the score note by note (default)', action="store_true", default=True) - parser.add_argument("--measure-cursor", dest="measureCursor", + group_cursors.add_argument("--measure-cursor", dest="measureCursor", help='generate a cursor following the score measure by measure', action="store_true", default=False) - parser.add_argument("-t", "--title-at-start", dest="titleAtStart", + 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) + + 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_argument("--title-duration", dest="titleDuration", + group_startend.add_argument("--title-duration", dest="titleDuration", help='time to display the title screen [%(default)s]', type=int, metavar="SECONDS", default=3) - parser.add_argument("--ttf", "--title-ttf", dest="titleTtfFile", + group_startend.add_argument("--ttf", "--title-ttf", dest="titleTtfFile", help='path to TTF font file to use in title', metavar="FONT-FILE") - parser.add_argument("--windows-ffmpeg", dest="winFfmpeg", + + 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_argument("--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_argument("-d", "--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_argument("-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_argument("-v", "--version", dest="showVersion", + group_debug.add_argument("-v", "--version", dest="showVersion", help="show program version", action="store_true", default=False) From 23033cda0932dfe848a82c7cfb695407629beb22 Mon Sep 17 00:00:00 2001 From: Mathieu Giraud Date: Tue, 11 Mar 2014 15:24:59 +0200 Subject: [PATCH 29/29] argparse: update help messages for some options --- ly2video.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ly2video.py b/ly2video.py index d608f6c..8768a9b 100755 --- a/ly2video.py +++ b/ly2video.py @@ -803,7 +803,7 @@ def parseOptions(): group_inout.add_argument("-b", "--beatmap", help='name of beatmap file for adjusting MIDI tempo', - metavar="FILE") + metavar="BEATMAP-FILE") group_inout.add_argument("--slide-show", dest="slideShow", help="input file prefix to generate a slide show (see doc/slideshow.txt)", @@ -852,7 +852,7 @@ def parseOptions(): group_cursors = parser.add_argument_group(title='Cursors') group_cursors.add_argument("-c", "--color", - help='name of color of middle bar [%(default)s]', + 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', @@ -864,7 +864,7 @@ def parseOptions(): 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) + 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')