|
Programmer's Notebook |
All computer source code presented on this page, unless it includes attribution to another author, is provided by Ed Halley under the Artistic License. Use such code freely and without any expectation of support. I would like to know if you make anything cool with the code, or need questions answered.
python/ bindings.py boards.py buzz.py cache.py cards.py constraints.py english.py getopts.py gizmos.py goals.py improv.py interpolations.py jquiz.py jquiz.zip jtopics.py namespaces.py nihongo.py nodes.py octalplus.py patterns.py persist.py physics.py pieces.py productions.py quizzes.py recipes.py relays.py romaji.py sheets.py strokes.py subscriptions.py svgbuild.py testing.py things.py timing.py ucsv.py useful.py uuid.py vectors.py weighted.py java/ perl/ cxx/ |
#!/opt/local/bin/python ''' Produces a series of image files to animate the build-up of a SVG graphic. ABSTRACT This script takes a SVG (scaleable vector graphics) file, and uses the InkScape application to render each frame of a movie animation. If viewed in sequence, a virtual "camera" is animated along a tour of the image as it is constructed, entity by entity, from nothing up to the final construction. The final movie rebuilds the graphic from the bottom-most layer up to the top-most layer. The script ignores hidden layers (i.e., whenever it finds style="display:none"). The virtual "camera" pans and zooms to the bounding area of each element smoothly, and then the new element is added to the composition. Depending on system performance and drawing complexity, expect a rendering speed of one frame per second. Be wary of any special effects offered in newer InkScape versions; in particular, zooming in close near a highly blurred path can cause rendering time to skyrocket. SYNOPSIS % svgbuild --path --text monalisa.svg ... Rendered movie/monalisa06789.png Time: 3h:08m OPTIONS % svgbuild --help % svgbuild -h Command line options offer control over many aspects of rendering the animation, such as camera movement, element drawing style, or where to put the rendered frames. Long form: Short: Default value: Purpose: ---------- ------ -------------- -------------------------- --path -p False/Off build up paths visually --text -t False/Off build up text visually --image -i False/Off build up images visually --width -w 640 pixel width of frames --height -h 480 pixel height of frames --folder -f 'movie' location to put frames --name -n 'movie' prefix for each frame --temp -T 'temp.svg' name of temporary svg --background -B 'white' web color behind svg --zoom -z 8.0 limit zoom to 1/z of whole --dally -d 4 hold after drawing element --dolly -D 50 time limit for panning --hold -H 100 extra hold frames at end --from -F 0 first frame to be written --until -U 99999 last frame to be written -X do not write any frames AUTHOR Ed Halley (ed@halley.cc) 6 April 2008 VERSION This is a complete reworking of the script to be a bit more general, working with the lxml etree library to manipulate the elements of the XML in a recursive fashion. No "xml pretty printer" is required to run now. This will hopefully make it easier to get running on multiple platforms with less compatibility hassle. In addition, the frame name format and range has moved to five digits, and the writing of frames in a given numerical window has been added. If you notice something awry during rendering, you can stop, fix the SVG element in question, and restart at a frame just before the problem element was being built. Running without rendering any frames will let you see the order that each element is found, and at what frame number it would be introduced in the animation. BUGS Certainly there are plenty of buried assumptions and things that will break when faced with the unexpected. TIPS and TRICKS The output is a series of PNG image files, in a subdirectory with serial numbered filenames that are five digits long. For example, movie/movie00023.png is the 24th frame of animation. To join those as an animation, many third-party tools can take an image sequence like this. QuickTime Pro has an "Open Image Sequence" menu choice. The mplayer project's mencoder or ffmpeg tools also work. Here's an ffmpeg command line the author uses: % ffmpeg -r 24 -b 4000 -aspect 1 \ -i 'movie/movie%05d.png' \ -i 'soundtrack.mp3' \ -f mpegvideo \ -y 'movie/movie.mpeg' REFERENCES Also uses some python routines for vector math and interpolations, to be found at http://halley.cc/code ''' # Arrange or adjust these if they're not on your PATH or platform. # import os if os.name == 'nt': # see http://kaioa.com/node/42 to learn how to make inker.bat inkscape = r'd:\bin\Inkscape\inker.bat' temporary = r't:\TEMP\_svg' identify = r'd:\bin\ImageMagick\identify.exe' convert = r'd:\bin\ImageMagick\convert.exe' else: inkscape = r'"/Applications/Art Tools/Inkscape.app/Contents/Resources/bin/inkscape"' temporary = r'/tmp/_svg' identify = '/opt/local/bin/identify' convert = '/opt/local/bin/convert' import interpolations import vectors ; from vectors import * from lxml import etree import shutil import time import sys import re import os #---------------------------------------------------------------------------- # conveniences def _qx(cmd, verbose=False): # naive version of qx() is not portable to Windows if verbose: print cmd output = os.popen(cmd + ' 2>/dev/null').read() return output def qx(cmd, verbose=False): '''Just like qx// or backticks operator from Perl, running the command and returning the STDOUT results as a string. Optional echo of the command issued first. ''' run = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) (out, err) = run.communicate() if run.returncode != 0: return 'Return code: %d' % run.returncode return out def system(cmd, verbose=False): '''Just like os.system() but with optional echo of the command.''' if verbose: print cmd return os.system(cmd) def boolify(value): '''Take a friendly user input value, and turn it into True or False.''' if value in (True, 1, 'y', 'yes', 'on', 'enable'): return True if value in (None, False, 0, 'n', 'no', 'off', 'disable'): return False return True def getopt(arg, tail, opt, default): '''Super-lightweight implementation of one --option=value parsing. Supports: -o / --option (returns True if default is a bool) -o=value / --option=value (returns value in same type as default) -o value / --option value (returns value in same type as default) Pops values from tail (usually remainder of argv list) only if required. ''' value = None o = opt[0] opt = opt.lower() match = re.match(r"^(-%s|--%s)$" % (o, opt), arg) if match: if isinstance(default, (bool, type(None))): return True if not len(tail): raise ValueError, 'Option %s needs an argument.' value = tail.pop(0) else: match = re.match(r"^(-%s|--%s)=(.*)$" % (o, opt), arg) if match: value = match.group(2) if value is None: return default if isinstance(default, bool): return boolify(value.lower()) if isinstance(default, int): return int(value) if isinstance(default, float): return float(value) return value def usage(this, options): '''Super-lightweight implementation of command-line usage help. Does not have anything particularly wordy about the meanings of each option and inputfiles. ''' print 'usage:', this, '<options>', '<inputfiles>' print 'options and (default) values:' for option in options: print '\t--%-15s\t(%s)' % (option, repr(options[option])) sys.exit(1) def getopts(argv, options): '''Super-lightweight implementation of command-line argument parsing. Give it the sys.argv list (without the script name), and a dict of default values, like: options = { 'flag': False, # -f,--flag,--flag=Yes,-f False 'number': 3, # -n 2, -n=4, --number=6,-n 5 'Name': 'Sally', # -N Mary,--name John,--name=Bill } Assumes initial letters are unique and --options are lowercase. (Especially note the -n/--number and -N/--name examples above.) Does no fancy unique-prefix magic to determine useful options. Does no list or increment handling for -n=3 -n=4 or +v +v +v. Does not indicate any ordering of options received; last value wins. Returns list of all non-option arguments in order found, including any lone - argument. Anything after a -- are non-option arguments. ''' others = [ ] this = argv.pop(0) while argv: arg = argv.pop(0) if arg == '--': others.extend(argv) argv[:] = [] if arg in ('-h', '-?', '--help'): usage(this, options) elif len(arg) and arg[0] == '-' and arg != '-': for opt in options: options[opt] = getopt(arg, sys.argv, opt, options[opt]) else: others.append(arg) return others #---------------------------------------------------------------------------- class SVG: def __init__(self): '''Prepares the virtual svg drawing container.''' self.filename = None self.tree = None self.root = None self.ids = { } def survey(self): '''Scan through the XML entities to ensure proper id attributes. InkScape files write a unique id for each element, and gives a general "flipped Y" coordinate space inside a page of known size. Non-InkScape SVG files may not comply with these optional niceties. We check that these features are available so InkScape can render and resolve rendering locations for every entity later on. ''' # ensure at least a default page size (arbitrarily, us letter) if 'width' not in self.root.attrib: self.root.attrib['width'] = '744.09448819' if 'height' not in self.root.attrib: self.root.attrib['height'] = '1052.3622047' # scan all elements in tree elements = [ self.root ] + self.root.findall(".//*") self.ids = { } for element in elements: if 'id' in element.attrib: self.ids[element.attrib['id']] = element # if any have no id at all, give them a new unique id unique = 0 for element in elements: if not 'id' in element.attrib: id = 'uniq%d' % unique while id in self.ids: unique += 1 id = 'uniq%d' % unique element.attrib['id'] = id self.ids[id] = element print 'Surveyed %d elements.' % len(self.ids.keys()) return len(self.ids.keys()) def read(self, filename): '''Requests the XML data be read from a file.''' self.filename = filename self.tree = etree.parse(filename) self.root = self.tree.getroot() self.survey() #---------------------------------------------------------------------------- class Camera: def __init__(self, options): '''Construct a virtual camera.''' self.locked = False self.time = 0 self.area = [ 0., 0., 1., 1. ] self.temp = options['folder'] + '/' + options['Temp'] self.width = float(options['width']) self.height = float(options['height']) self.dally = options['dally'] self.dolly = options['Dolly'] self.layout = { } def _write(self, svg): # Save a scratch prepared copy of the xml to be used by InkScape file = open(self.temp, 'w') file.write(etree.tostring(svg.root, pretty_print=True)) file.close() def survey(self, svg): '''Learn the locations of all elements.''' if self.layout: return self._write(svg) # ask inkscape for a survey of all ids settings = ' '.join( [ '-z', '--query-all', ] ) command = ' '.join( [ inkscape, settings, self.temp ] ) result = qx(command) result = result.split('\n') layout = self.layout page = [ float(svg.root.attrib['width']), float(svg.root.attrib['height']) ] for line in result: fields = line.split(',') if len(fields) != 5: continue area = [ float(x) for x in fields[1:] ] area[2] += area[0] area[3] += area[1] layout[fields[0]] = self._flip(area, page) self.limit = max(page) / options['zoom'] print 'Surveyed %d element locations.' % len(layout.keys()) return len(layout.keys()) def cleanup(self): '''Remove any temporary files required for rendering.''' if os.path.exists(self.temp): os.unlink(self.temp) def _flip(self, area, page): '''Helper to turn --query-all rects into rendering area rects.''' flipped = list(area[:]) high = abs(area[3]-area[1]) flipped[1] = page[1] - min(area[1],area[3]) - high flipped[3] = flipped[1] + high if flipped[2] < flipped[0]: flipped[0],flipped[2] = flipped[2],flipped[0] return flipped def locate(self, target): '''Find a target (element id or area rect) and convert it as necessary to return the area rect. ''' area = None if isinstance(target, list): area = target elif target in self.layout: area = self.layout[target] return area def move(self, target): '''Find a target (element id or area rect) and move camera to view it instantly. ''' area = self.locate(target) if area: self.area = area return area def _extent(self, target, fill=False): # Adjusts a target area to match the camera's proper aspect ratio. area = self.locate(target) if area[3] == area[1]: area[3] += 1 high = float(area[3]-area[1]) wide = float(area[2]-area[0]) ratio = wide / high shape = self.width / self.height if (ratio > shape) == fill: mid = float(area[2]+area[0])/2. wide = high * shape area[0] = mid - wide/2. area[2] = mid + wide/2. else: mid = float(area[3]+area[1])/2. high = wide / shape area[3] = mid + high/2. area[1] = mid - high/2. return area def fill(self, target): '''Adjusts an area to ensure its center fills the camera's view.''' return self._extent(target, fill=True) def fit(self, target): '''Adjusts an area to ensure it fits within the camera's view.''' return self._extent(target, fill=False) def speed(self, before, after): '''Given two rectangles, calculate how many frames of animation to spend on a nice swoop from one to the other. Number of frames is bounded. ''' page = [ float(svg.root.attrib['width']), float(svg.root.attrib['height']) ] before = V( (before[2]+before[0])/2., (before[3]+before[1])/2. ) after = V( (after[2]+after[0])/2., (after[3]+after[1])/2. ) dist = vectors.distance(before, after) ts = int(interpolations.linear( 0, max(page), dist, self.dally, self.dolly )) ts = min(max(self.dally, ts), self.dolly) return ts def zoom(self, target, amount=1.0): '''Given a target area rect, ensures the area is not too small.''' area = self.locate(target) if area[3] == area[1]: area[3] += 1 high = float(area[3]-area[1]) wide = float(area[2]-area[0]) ratio = wide / high if high < self.limit: high = self.limit wide = high * ratio mid = float(area[2]+area[0])/2. wide *= amount area[0] = mid - wide/2. area[2] = mid + wide/2. mid = float(area[3]+area[1])/2. high *= amount area[3] = mid + high/2. area[1] = mid - high/2. return area def shoot(self, svg, marker='>'): '''Render one image at the current camera position.''' # Includes two hacks (spill and convert -extent) # to fix imprecise image output sizes. # Also applies background color to avoid alpha movie problems. if options['From'] <= self.time <= options['Until']: time.sleep(0.250) self._write(svg) output = "%s/%s%05d.png" % (options['folder'], options['name'], self.time) spill = (self.area[3]-self.area[1]) / 20. area = "%d:%d:%d:%d" % (self.area[0], self.area[1], self.area[2], self.area[3] + spill) settings = ' '.join( [ '-z', '--export-png=%s' % output, '--export-area=%s' % area, '--export-width=%d' % options['width'], ] ) command = ' '.join( [ inkscape, settings, self.temp ] ) results = qx(command) conversion = ' '.join( [ '-background %s' % options['Background'], '-flatten', '-extent %dx%d+0+0!' % (options['width'], options['height']), ] ) command = ' '.join( [ convert, output, conversion, output, '&' ] ) results = qx(command) print ' ' + marker, output self.time += 1 def hold(self, ts=1): if ts <= 0: return before = "%s/%s%05d.png" % (options['folder'], options['name'], self.time-1) for i in range(ts): after = "%s/%s%05d.png" % (options['folder'], options['name'], self.time) if options['From'] <= self.time <= options['Until']: shutil.copyfile(before, after) print ' =', after self.time += 1 def pan(self, svg, target, ts=0, margin=1.0): '''Shoots the intervening frames from the current camera area toward a target camera area. The camera speed eases into the motion and eases to a stop, rather than lurching with a simple linear interpolation, but the motion is in a direct path. ''' before = self.area if isinstance(target, list): pass elif target in self.layout: target = self.zoom(self.fit(self.locate(target)), margin) else: return if not ts: ts = self.speed(before, target) a = V(before) b = V(before) c = V(target) d = V(target) for i in range(ts): tt = (i + 1) / float(ts) where = interpolations.bezier( tt, a, b, c, d ).list() self.move(where) self.shoot(svg, marker='-') #---------------------------------------------------------------------------- def build_image(svg, camera, entity, options): '''Special progressive drawing of a path element.''' href = '{http://www.w3.org/1999/xlink}href' if not href in entity.attrib: return if not os.path.exists(identify): print 'ImageMagick "identify" tool not found; skipping.' return if not os.path.exists(convert): print 'ImageMagick "convert" tool not found; skipping.' return img = entity.attrib[href] if not os.path.exists(img): print 'Image file not found locally:', img return # figure out original image's pixel size results = qx('%s %s' % (identify, img)) m = re.search(r'(\d+)x(\d+)', results) if not m: print 'ImageMagick could not identify size of image; skipping.' return size = [ int(m.group(1)), int(m.group(2)) ] # for a handful of frames, replace image with a truncated temporary image tmp = options['folder'] + '/temp.png' frames = int(options['dally']) * 4 for frame in range(frames): height = interpolations.linear(0, frames, frame, 1, size[1]) command = ' '.join( [ convert, '-type TrueColorMatte', '-channel alpha', img, '-background "#00000000"', '-crop %dx%d+0+0' % (size[0], height), '-extent %dx%d' % (size[0], size[1]), tmp ] ) results = qx(command) if os.path.exists(tmp): entity.attrib[href] = tmp camera.shoot(svg) os.unlink(tmp) # replace the original image reference entity.attrib[href] = img camera.shoot(svg) return def build_path(svg, camera, entity, options): '''Special progressive drawing of a path element.''' if not 'd' in entity.attrib: return # replace style with our own style style = '' if 'style' in entity.attrib: style = entity.attrib['style'] width = (camera.area[3]-camera.area[1]) / float(camera.height) hairline = ''.join([ 'opacity:1;', 'overflow:visible;', 'fill:none;', 'fill-opacity:0.;', 'fill-rule:nonzero;', 'stroke:#000000;', 'stroke-width:%f;' % width, 'stroke-linecap:round;', 'stroke-linejoin:round;', 'marker:none;', 'marker-start:none;', 'marker-mid:none;', 'marker-end:none;', 'stroke-miterlimit:4;', 'stroke-dasharray:none;', 'stroke-dashoffset:0;', 'stroke-opacity:1;', 'visibility:visible;', 'display:inline;', 'enable-background:accumulate' ]) entity.attrib['style'] = hairline # scan the control points points = entity.attrib['d'].split(' ') built = [ ] # each control point is a letter, followed by some floating-point pairs while points: built.append( points.pop(0) ) while points and not re.match(r'^[a-zA-Z]$', points[0]): built.append( points.pop(0) ) # add the point to our path entity.attrib['d'] = ' '.join(built) camera.shoot(svg) # put the original style back entity.attrib['style'] = style camera.shoot(svg) def build_text(svg, camera, entity, options): '''Special progressive drawing of a text or tspan contents.''' text = entity.text entity.text = '' # if we have children, recurse to build their .text now if entity.getchildren(): children = [ ] for child in entity.iterchildren(): if child.text: children.append(child) for child in children: entity.remove(child) for child in children: entity.append(child) build_text(svg, camera, child, options) # come back to build our own direct text if not text: return for l in range(1, len(text)): entity.text = text[:l] camera.shoot(svg) entity.text = text camera.shoot(svg) def build(svg, camera, entity, options): '''Recursively build up the given entity, by removing all its children and adding them back in one at a time, and shooting the progress with the given camera. ''' id = entity.attrib['id'] name = id label = 'http://www.inkscape.org/namespaces/inkscape}label' if label in entity.attrib: name = entity.attrib[label] print '%05d - Building up <%s id="%s"> (%s)...' % (camera.time, entity.tag, id, name) nobuild = set([ '{http://www.w3.org/2000/svg}defs', '{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}namedview', '{http://www.w3.org/2000/svg}metadata', ]) nochild = set([ '{http://www.w3.org/2000/svg}text', ]) backable = set([ '{http://www.w3.org/2000/svg}g', ]) ripped = [ ] for child in entity.iterchildren(): if child.tag in nobuild: continue if not child.attrib['id'] in camera.layout: continue if 'style' in child.attrib: if 'display:none' in child.attrib['style']: continue ripped.append(child) for child in ripped: entity.remove(child) backward = True if not entity.tag in backable: backward = False if not 'back' in id and not 'back' in name: backward = False if backward: print ' (Building children of entity %s backwards.)' % id ripped.reverse() for child in ripped: print ' Adding child <%s id="%s">...' % (child.tag, child.attrib['id']) camera.pan(svg, child.attrib['id'], margin=1.2) if backward: entity.insert(0, child) else: entity.append(child) if child.getchildren() and not child.tag in nochild: build(svg, camera, child, options) elif options['path'] and re.search(r"\}path$", child.tag): build_path(svg, camera, child, options) elif options['image'] and re.search(r"\}image$", child.tag): build_image(svg, camera, child, options) elif options['text'] and re.search(r"\}text$", child.tag): build_text(svg, camera, child, options) camera.shoot(svg) camera.hold(options['dally'] - 1) camera.pan(svg, id) #---------------------------------------------------------------------------- # operate as a command-line driven tool if __name__ == "__main__": # process command-line options options = { 'folder': 'movie', 'name': 'movie', 'Temp': 'temp.svg', 'From': 0, 'Until': 99999, 'image': False, 'path': False, 'text': False, 'width': 640, 'height': 480, 'dally': 4, 'Dolly': 50, 'Hold': 100, 'Background': 'white', 'zoom': 8., 'Xx': False, } this = sys.argv[0] files = getopts(sys.argv, options) if not files: print 'No SVG files were specified.' usage(this, options) if options['width'] < 1 or options['height'] < 1: print 'Invalid output pixel --height or --width specified.' usage(this, options) if zero(options['zoom']) or options['zoom'] < 0: print 'Zoom limiting value is invalid; must be positive.' usage(this, options) if options['Xx']: options['From'] = options['Until'] = -1 # overall preparations overall = time.time() if not os.path.exists(options['folder']): os.mkdir(options['folder']) # build each file in turn with same options for file in files: try: start = time.time() print 'Starting buildup of %s...' % file svg = SVG() svg.read(file) camera = Camera(options) if camera.survey(svg): camera.move(svg.root.attrib['id']) build(svg, camera, svg.root, options) print 'Finishing...' camera.hold(options['Hold']) camera.cleanup() finish = time.time() hrs = int((finish - start) / 60) / 60 min = int((finish - start) / 60) % 60 folder = options['folder'] print 'Finished %s to %s in %dh:%02dm.' % (file, folder, hrs, min) except type(None), e: # except Exception, e: print str(e) # overall summary if multiple files given if len(files) > 1: finish = time.time() hrs = int((finish - start) / 60) / 60 min = int((finish - start) / 60) % 60 print 'Done in %dh:%02dm overall.' % (hrs, min) |
|
Contact Ed Halley by email at
ed@halley.cc. Text, code, layout and artwork are Copyright © 1996-2008 Ed Halley. Copying in whole or in part, with author attribution, is expressly allowed. Any references to trademarks are illustrative and are controlled by their respective owners. |
|