multiblend

changeset 19:feeb91f27b35

Made multiblend a module
author Sybren Stüvel <sybren@stuvel.eu>
date Mon, 27 Oct 2008 17:09:10 +0100
parents d43d639874bf
children 6e08d806087a
files multiblend.py multiblend/__init__.py
diffstat 2 files changed, 961 insertions(+), 961 deletions(-) [+]
line diff
     1.1 --- a/multiblend.py	Mon Oct 27 00:16:16 2008 +0100
     1.2 +++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.3 @@ -1,961 +0,0 @@
     1.4 -#!/usr/bin/python
     1.5 -# -*- coding: utf-8 -*-
     1.6 -
     1.7 -'''Multiblend - simple distributed Blender rendering.
     1.8 -
     1.9 -Usage:
    1.10 -    multiblend.py \\
    1.11 -            -b <blenderfile> \\
    1.12 -            -s <startframe> \\
    1.13 -            -e <endframe> \\
    1.14 -            [-T] \\
    1.15 -            [-S Scene name] \\
    1.16 -            [-E]
    1.17 -
    1.18 -    The -T option will cause Multiblend to skip testing the nodes.
    1.19 -    The -S option can denote the scene to render.
    1.20 -    The -E option will cause Multiblend to skip frames that already exist.
    1.21 -
    1.22 -Configuration
    1.23 -=============
    1.24 -
    1.25 -Configuration is read from /etc/multiblend and ~/.multiblendrc. Below
    1.26 -you find an example config file::
    1.27 -
    1.28 -    [main]
    1.29 -    chunks=100
    1.30 -    nodes=2
    1.31 -    outputpath=/home/sybren/documents/blender/output
    1.32 -    ssh=/usr/bin/ssh -Tq
    1.33 -    scp=/usr/bin/scp -q
    1.34 -    echoing_shell=False
    1.35 -    nice=3
    1.36 -
    1.37 -    [node0]
    1.38 -    hostname=localhost
    1.39 -    blender=/home/sybren/blender-2.41-linux-glibc232-py24-i386/blender
    1.40 -    workdir=/home/sybren/tmp
    1.41 -
    1.42 -    [node1]
    1.43 -    hostname=zebra
    1.44 -    blender=/home/sybren/blender-2.41-linux-glibc232-py24-i386/blender
    1.45 -    workdir=/tmp
    1.46 -
    1.47 -The list of nodes should contain the hostnames or IP addresses of the
    1.48 -nodes, the path to the Blender executable, and the working directory.
    1.49 -The number of nodes should be set in the [main] section.
    1.50 -
    1.51 -The 'echoing_shell' option is optional and defaults to False. It can
    1.52 -be set in the [main] section for all the nodes, and can be specified
    1.53 -for specific nodes. First try without this option. If you get the
    1.54 -message that Blender is not executable on nodes where it actually is
    1.55 -executable, try echoing_shell=True.
    1.56 -
    1.57 -WARNING: The working directory should be different from the directory
    1.58 -the blender file is in. Otherwise, you might lose your work.
    1.59 -
    1.60 -Setting up a node
    1.61 -=================
    1.62 -
    1.63 -The node where this script is run, is called the "master node". All
    1.64 -other nodes are just called "node".
    1.65 -
    1.66 -The node should be reachable from the master node using SSH, without
    1.67 -having to type a password. Use 'ssh-keygen' to generate a suitable SSH
    1.68 -key, and use 'ssh-copy-id' to copy the key from the master node to the
    1.69 -other nodes. That generally does the trick. If you want to run a
    1.70 -Blender instance on the master node as well, include a node with
    1.71 -hostname=localhost.
    1.72 -
    1.73 -License
    1.74 -=======
    1.75 -
    1.76 -This software is covered by the Gnu Public License, or GPL. For more
    1.77 -information, see http://www.stuvel.eu/license.
    1.78 -'''
    1.79 -
    1.80 -# Changes:
    1.81 -#
    1.82 -# 2006-05-01, version 1.0
    1.83 -#  - Initial version/home/sstuvel/sadako/presentaties/20060520:
    1.84 -#
    1.85 -# 2006-05-01, version 1.1
    1.86 -#  - The used chunk size is the maximum of the configured chunk size,
    1.87 -#    and the number of frames to render divided by the number of
    1.88 -#    nodes. This tries to ensure all nodes are used, even for a small
    1.89 -#    number of rendered frames.
    1.90 -#  - Logging prints node hostname instead of number.
    1.91 -#  - Pressing Ctrl+C properly stops Blender on all nodes.
    1.92 -#
    1.93 -# 2006-05-10, version 1.2
    1.94 -#  - No longer need NFS, files are copied via scp instead. This does
    1.95 -#    add the requirement that all external files (fonts, textures,
    1.96 -#    etc.) either should be already available on the nodes, or be
    1.97 -#    packed inside the Blender file.
    1.98 -#  - Removed the special hostname LOCAL. In its stead, just use
    1.99 -#    hostname=localhost. This made the code more stable and clean.
   1.100 -#
   1.101 -# 2005-05-29, version 1.3
   1.102 -#  - Start 'sh' immediately on the node. This prevents 'last logged in
   1.103 -#    on ...' messages upon login.
   1.104 -#  - Added echoing_node to the config possibilities.
   1.105 -#  - Added nice level to the config possibilities.
   1.106 -#  - Added possibility to skip node testing.
   1.107 -#  - Fixed chunk size bug when nr of nodes is larger than nr of frames
   1.108 -#    to render.
   1.109 -#  - Fixed encoding bug when stdout is not a terminal.
   1.110 -#  - Added possibility to select the scene to render.
   1.111 -#
   1.112 -# in development, version 1.4
   1.113 -#  - Display timing info after rendering.
   1.114 -#  - Added -E option to skip existing frames.
   1.115 -#
   1.116 -# Ideas for future versions:
   1.117 -#  - Perform check on all nodes, output multiblendrc file with only
   1.118 -#    those nodes that passed the checks.
   1.119 -#  - Display list of missing frames, optionally rendering only those.
   1.120 -
   1.121 -__author__ = 'Sybren Stüvel'
   1.122 -__email__ = 'sybren@stuvel.eu'
   1.123 -__revision__ = '1.4-beta0'
   1.124 -__url__ = 'http://www.stuvel.eu/multiblend'
   1.125 -
   1.126 -import ConfigParser
   1.127 -import datetime
   1.128 -import getopt
   1.129 -import glob
   1.130 -import logging
   1.131 -import md5
   1.132 -import os
   1.133 -import os.path
   1.134 -import popen2
   1.135 -import random
   1.136 -import StringIO
   1.137 -import sys
   1.138 -import threading
   1.139 -import time
   1.140 -
   1.141 -logging.basicConfig()
   1.142 -log = logging.getLogger('multiblend')
   1.143 -log.setLevel('DEBUG' in os.environ and logging.DEBUG or logging.INFO)
   1.144 -
   1.145 -node_loglevel = logging.INFO
   1.146 -
   1.147 -class Node(object):
   1.148 -    '''Representation of a node.'''
   1.149 -
   1.150 -    def __init__(self, config, name):
   1.151 -        self.config = config
   1.152 -        self.name = name
   1.153 -        self.hostname = config[name]['hostname']
   1.154 -        self.blender = config[name]['blender']
   1.155 -        self.workdir = config[name]['workdir']
   1.156 -        self.echoing_shell = config[name]['echoing_shell']
   1.157 -        self.outputpath = config['outputpath']
   1.158 -        self.log = logging.getLogger(self.hostname)
   1.159 -        self.log.setLevel(node_loglevel)
   1.160 -
   1.161 -        self.shell = None
   1.162 -        self.rendering = False
   1.163 -        self.blenderpid = None
   1.164 -    
   1.165 -    def needs_connection(func):
   1.166 -        '''Function decorator.
   1.167 -
   1.168 -        Ensures there is a connected shell before the decorated
   1.169 -        function is called.
   1.170 -        '''
   1.171 -
   1.172 -        def connect_and_call(self, *args, **kwargs):
   1.173 -            '''Connects if self.shell is None, then calls the
   1.174 -            decorated funcion.
   1.175 -            '''
   1.176 -            if self.shell is None:
   1.177 -                self.log.debug('Implicitly connecting to call %s' % func)
   1.178 -                self.connect()
   1.179 -
   1.180 -            return func(self, *args, **kwargs)
   1.181 -
   1.182 -        return connect_and_call
   1.183 -        
   1.184 -    def connect(self):
   1.185 -        '''Connects to this node.
   1.186 -
   1.187 -        Sets self.shell to the connected shell. Use self.send() and
   1.188 -        self.readline() to communicate with host.
   1.189 -
   1.190 -        Returns self.shell.
   1.191 -        '''
   1.192 -
   1.193 -        self.shell = self.create_connection()
   1.194 -        if not self.shell:
   1.195 -            self.log.critical('Unable to create shell!')
   1.196 -            raise RuntimeError('Unable to create shell!')
   1.197 -
   1.198 -        self.send_blenderstarter()
   1.199 -
   1.200 -        return self.shell
   1.201 -
   1.202 -    def __del__(self):
   1.203 -        '''Cleans up the node'''
   1.204 -
   1.205 -        self.log.debug('Cleaning up')
   1.206 -
   1.207 -        if not self.shell:
   1.208 -            # No connection was made, so nothing to clean up
   1.209 -            return
   1.210 -
   1.211 -        try:
   1.212 -                self.remove_blenderstarter()
   1.213 -        except:
   1.214 -            self.log.exception('Error while removing blenderstarter')
   1.215 -
   1.216 -        try:
   1.217 -            self.disconnect()
   1.218 -        except:
   1.219 -            self.log.exception('Error while disconnecting')
   1.220 -
   1.221 -    def create_connection(self):
   1.222 -        '''Connects to this node.
   1.223 -
   1.224 -        Returns a popen2.Popen3 object connected to the shell.
   1.225 -        '''
   1.226 -
   1.227 -        cmd = '%s %s sh' % (config['ssh'], self.hostname)
   1.228 -
   1.229 -        self.log.debug('Starting "%s"' % cmd)
   1.230 -        shell = DebuggingPopen(self.log, cmd)
   1.231 -        if not shell:
   1.232 -            self.log.critical('Unable to create shell!')
   1.233 -            raise RuntimeError('Unable to create shell!')
   1.234 -
   1.235 -        if self.echoing_shell:
   1.236 -            shell.send('stty -echo')
   1.237 -            shell.readline()
   1.238 -        shell.send('export TERM=vt100')
   1.239 -
   1.240 -        return shell
   1.241 -
   1.242 -    def disconnect(self):
   1.243 -        '''Disconnects this node.
   1.244 -        
   1.245 -        Returns the exit status. See DebuggingOpen.wait()
   1.246 -        '''
   1.247 -
   1.248 -        status = self.disconnect_connection(self.shell)
   1.249 -        self.shell = None
   1.250 -
   1.251 -        return status
   1.252 -    
   1.253 -    def disconnect_connection(self, shell):
   1.254 -        '''Disconnects the shell.
   1.255 -
   1.256 -        Returns the exit status. See DebuggingOpen.wait()
   1.257 -        '''
   1.258 -
   1.259 -        shell.send('exit')
   1.260 -        shell.tochild.close()
   1.261 -        shell.fromchild.close()
   1.262 -
   1.263 -        return shell.wait()
   1.264 -
   1.265 -    @needs_connection
   1.266 -    def send(self, line):
   1.267 -        '''Sends 'line' to the shell'''
   1.268 -        return self.shell.send(line)
   1.269 -
   1.270 -    @needs_connection
   1.271 -    def readline(self):
   1.272 -        '''Returns a line from the shell'''
   1.273 -
   1.274 -        return self.shell.readline()
   1.275 -
   1.276 -    @needs_connection
   1.277 -    def test_blender_executable(self):
   1.278 -        '''Tests the existance and executable-ness of blender.'''
   1.279 -
   1.280 -        blender = self.blender
   1.281 -        self.send('[ -x %s ] && echo TRUE || echo FALSE' % blender)
   1.282 -        
   1.283 -        if self.readline().strip() == 'TRUE':
   1.284 -            return True
   1.285 -
   1.286 -        self.log.error('Blender %s is not executable on node %s' % (blender,
   1.287 -            self.name))
   1.288 -        return False
   1.289 -
   1.290 -    def test(self):
   1.291 -        '''Tests this node.
   1.292 -
   1.293 -        Returns True if the node is okay, returns False otherwise.
   1.294 -        '''
   1.295 -
   1.296 -        # No longer an issue, since we no longer use NFS
   1.297 -        #if not self.test_create_file():
   1.298 -        #   return False
   1.299 -
   1.300 -        try:
   1.301 -            if not self.test_blender_executable():
   1.302 -                return False
   1.303 -        except Exception, e:
   1.304 -            self.log.exception('Error while testing node')
   1.305 -            return False
   1.306 -
   1.307 -        self.log.debug('Host %s okay' % self.hostname)
   1.308 -
   1.309 -        return True
   1.310 -
   1.311 -    def __str__(self):
   1.312 -        return '[%s %s]' % (self.name, self.hostname)
   1.313 -    
   1.314 -    def __unicode__(self):
   1.315 -        return unicode(str(self))
   1.316 -
   1.317 -    def blend(self, blenderfile, start, end):
   1.318 -        '''Runs blender on the node.
   1.319 -
   1.320 -        Renders the file 'blenderfile; from frame 'start' to frame
   1.321 -        'end'. Does this in its own thread, returning when the blender
   1.322 -        job is done.
   1.323 -
   1.324 -        The frames are copied to the master node using scp.
   1.325 -        '''
   1.326 -
   1.327 -        self.send_blenderstarter()
   1.328 -
   1.329 -        blenderstarter = os.path.join(self.workdir, 'blenderstarter')
   1.330 -        
   1.331 -        remotefile = os.path.join(self.workdir, blenderfile)
   1.332 -        self.send_file(file(blenderfile), remotefile)
   1.333 -        blenderfile = remotefile
   1.334 -
   1.335 -        self.send('"%s" "%s" "%s" %i %i'
   1.336 -                % (blenderstarter, self.blender, blenderfile, start, end))
   1.337 -
   1.338 -        self.blenderpid = int(self.readline().strip())
   1.339 -
   1.340 -        line = None
   1.341 -        while line != 'DONE':
   1.342 -            line = self.readline().strip()
   1.343 -            
   1.344 -            # Skip empty lines and lines with \r in them - those
   1.345 -            # contain progress information which is annoying on a
   1.346 -            # multi-node system.
   1.347 -            if line and '\r' not in line:
   1.348 -                self.log.debug(line)
   1.349 -
   1.350 -            # Handle saved frames. Those are reported as
   1.351 -            # Saved: /tmp/0001.jpg Time: 00:00.70
   1.352 -            if line.startswith('Saved: '):
   1.353 -                try:
   1.354 -                    self.fetch_saved_file(line.split()[1])
   1.355 -                except RuntimeError, e:
   1.356 -                    self.log.error('Aborted due to RuntimeError: %s' % e)
   1.357 -                    self.rendering = False
   1.358 -                    raise
   1.359 -
   1.360 -        self.blenderpid = None
   1.361 -
   1.362 -    def renderchunks(self, blenderfile, dispatcher):
   1.363 -        '''Renders chunks from the dispatcher in a separate thread.
   1.364 -
   1.365 -        The dispatcher is queried for chunks of frames, and they are
   1.366 -        rendered. If no more frames are available, the thread stops.
   1.367 -
   1.368 -        This function returns immediately after starting the new
   1.369 -        thread.
   1.370 -
   1.371 -        Returns the thread object.
   1.372 -        '''
   1.373 -
   1.374 -        def thread():
   1.375 -            '''The thread that actually does the rendering.'''
   1.376 -
   1.377 -            self.rendering = True
   1.378 -
   1.379 -            while self.rendering:
   1.380 -
   1.381 -                # Get another chunk of frames
   1.382 -                chunk = dispatcher.chunk()
   1.383 -                if chunk is None:
   1.384 -                    self.rendering = False
   1.385 -                    break
   1.386 -
   1.387 -                # Render the frames
   1.388 -                self.log.info('rendering %s' % (chunk, ))
   1.389 -                (start, end) = chunk
   1.390 -
   1.391 -                start_time = time.time()
   1.392 -                self.blend(blenderfile, start, end)
   1.393 -                if time.time() - start_time < 2:
   1.394 -                    self.log.error('Rendering takes too little time, '
   1.395 -                    'something must be wrong. Aborted this node.')
   1.396 -                    self.rendering = False
   1.397 -
   1.398 -            self.log.info("I'm done, no more chunks left for me.")
   1.399 -
   1.400 -        renderthread = threading.Thread(target=thread)
   1.401 -        renderthread.setDaemon(True)
   1.402 -        renderthread.start()
   1.403 -
   1.404 -        return renderthread
   1.405 -
   1.406 -    def abort(self):
   1.407 -        '''Tries to abort this node.
   1.408 -        
   1.409 -        Creates a new SSH connection to the node, and kills the
   1.410 -        blender instance. It won't touch other blender instances
   1.411 -        running on the same host.
   1.412 -        '''
   1.413 -
   1.414 -        self.rendering = False
   1.415 -
   1.416 -        if self.blenderpid:
   1.417 -            killshell = self.create_connection()
   1.418 -            killshell.send('kill %i' % self.blenderpid)
   1.419 -    
   1.420 -    def fetch_saved_file(self, filename):
   1.421 -        '''Fetches the remote file 'filename' from the node, and
   1.422 -        stores it in the local output directory.
   1.423 -
   1.424 -        'filename' should be the filename on the node. It will be
   1.425 -        stored in the outputpath configured in the [main] section of
   1.426 -        the config file.
   1.427 -        '''
   1.428 -
   1.429 -        # Build the scp command
   1.430 -        outputpath = self.outputpath
   1.431 -        cmd = '%s %s:%s %s' % (self.config['scp'], self.hostname, filename, outputpath)
   1.432 -        self.log.debug('Running %s' % cmd)
   1.433 -
   1.434 -        # Copy the frame
   1.435 -        result = os.system(cmd)
   1.436 -
   1.437 -        # Check result of command. Do it thoroughly, because failure
   1.438 -        # to fetch the images defeats the entire rendering process.
   1.439 -        if os.WCOREDUMP(result):
   1.440 -            msg = 'Coredump while fetching %s' % filename
   1.441 -            self.log.critical(msg)
   1.442 -            raise RuntimeError(msg)
   1.443 -        
   1.444 -        if not os.WIFEXITED(result):
   1.445 -            msg = 'scp process did not finish properly while ' \
   1.446 -                    'fetching %s' % filename
   1.447 -            self.log.critical(msg)
   1.448 -            raise RuntimeError(msg)
   1.449 -
   1.450 -        status = os.WEXITSTATUS(result)
   1.451 -        if status > 0:
   1.452 -            msg = 'scp process exited with error status %i while ' \
   1.453 -                    'fetching %s' % (status, filename)
   1.454 -            self.log.critical(msg)
   1.455 -            raise RuntimeError(msg)
   1.456 -    
   1.457 -        self.log.info('Saved %s' % filename)
   1.458 -    
   1.459 -    @needs_connection
   1.460 -    def send_blenderstarter(self):
   1.461 -        '''Sends the blenderstarter script to the client.
   1.462 -
   1.463 -        The script starts blender in the background, reports the PID of
   1.464 -        blender so that we can kill it if required, then waits for Blender
   1.465 -        to finish. After that, DONE is echoed so we know it's done.
   1.466 -        '''
   1.467 -
   1.468 -        starter = '''\
   1.469 -        #!/bin/sh
   1.470 -
   1.471 -        if [ -z "$4" ]; then
   1.472 -            echo "Usage: $0 <blender> <blenderfile> <start> <end>"
   1.473 -            exit 1
   1.474 -        fi
   1.475 -
   1.476 -        nice -n %(nice)i $1 -b "$2" -s $3 -e $4 -a %(scene)s 2>&1 &
   1.477 -        BLENDERPID=$!
   1.478 -
   1.479 -        echo $BLENDERPID
   1.480 -        wait $BLENDERPID
   1.481 -        echo DONE
   1.482 -        '''.replace(8 * ' ', '')
   1.483 -
   1.484 -        # Set scene name option if a scene was passed
   1.485 -        if self.config['scene']:
   1.486 -            scene = '-S %s' % self.config['scene']
   1.487 -        else:
   1.488 -            scene = ''
   1.489 -
   1.490 -        starter = starter % {
   1.491 -                'nice': self.config['nice'],
   1.492 -                'scene': scene
   1.493 -            }
   1.494 -
   1.495 -        path = os.path.join(self.workdir, 'blenderstarter')
   1.496 -        self.send_file(StringIO.StringIO(starter), path)
   1.497 -        self.send('chmod +x %s' % path)
   1.498 -
   1.499 -    @needs_connection
   1.500 -    def send_file(self, fileobj, remotename):
   1.501 -        '''Sends the contents of file-like object 'fileobj' to the
   1.502 -        node, saving it as 'remotename'.
   1.503 -        '''
   1.504 -        
   1.505 -        tmpfilename = non_existing_file('/tmp')
   1.506 -        tmpfile = open(tmpfilename, 'w')
   1.507 -        tmpfile.write(fileobj.read())
   1.508 -        tmpfile.close()
   1.509 -
   1.510 -        cmd = '%s %s %s:%s' % (self.config['scp'], tmpfilename, self.hostname, remotename)
   1.511 -        self.log.debug('Running %s' % cmd)
   1.512 -
   1.513 -        # Copy the file
   1.514 -        result = os.system(cmd)
   1.515 -
   1.516 -        # Check result of command. Do it thoroughly, because failure
   1.517 -        # to fetch the images defeats the entire rendering process.
   1.518 -        if os.WCOREDUMP(result):
   1.519 -            msg = 'Coredump while sending %s' % remotename
   1.520 -            self.log.critical(msg)
   1.521 -            raise RuntimeError(msg)
   1.522 -        
   1.523 -        if not os.WIFEXITED(result):
   1.524 -            msg = 'scp process did not finish properly while ' \
   1.525 -                    'sending %s' % remotename
   1.526 -            self.log.critical(msg)
   1.527 -            raise RuntimeError(msg)
   1.528 -
   1.529 -        status = os.WEXITSTATUS(result)
   1.530 -        if status > 0:
   1.531 -            msg = 'scp process exited with error status %i while ' \
   1.532 -                    'sending %s' % (status, remotename)
   1.533 -            self.log.critical(msg)
   1.534 -            raise RuntimeError(msg)
   1.535 -    
   1.536 -        try:
   1.537 -            os.remove(tmpfilename)
   1.538 -        except IOError, e:
   1.539 -            self.log.warn('Unable to remove temporary file %s: %s' %
   1.540 -                    (tmpfilename, e))
   1.541 -
   1.542 -    @needs_connection
   1.543 -    def remove_blenderstarter(self):
   1.544 -        '''Removes the blender starter script.'''
   1.545 -
   1.546 -        blenderstarter = os.path.join(self.workdir, 'blenderstarter')
   1.547 -        self.send('rm -f %s' % blenderstarter)
   1.548 -
   1.549 -class DebuggingPopen(popen2.Popen3):
   1.550 -    '''Popen4 class, augmented for debugging and easier I/O. Easier
   1.551 -    for this project, anyway ;-)
   1.552 -    '''
   1.553 -
   1.554 -    def __init__(self, log, *args, **kwargs):
   1.555 -        self.log = log
   1.556 -        popen2.Popen3.__init__(self, *args, **kwargs)
   1.557 -
   1.558 -    def send(self, line):
   1.559 -        '''Sends a line of text to the process.
   1.560 -
   1.561 -        A newline is appended for your convenience.
   1.562 -        '''
   1.563 -
   1.564 -        self.log.debug('Sending: %s' % line)
   1.565 -        print >> self.tochild, line
   1.566 -        self.tochild.flush()
   1.567 -    
   1.568 -    def readline(self):
   1.569 -        '''Returns a line of text from the client.'''
   1.570 -
   1.571 -        line = self.fromchild.readline()
   1.572 -        stripped = line.strip()
   1.573 -        if stripped:
   1.574 -            self.log.debug('Received: %s' % stripped)
   1.575 -
   1.576 -        return line
   1.577 -
   1.578 -class FrameDispatcher(object):
   1.579 -    '''Frame dispatcher for render nodes.
   1.580 -
   1.581 -    The dispatcher is initialized with a starting and ending frame
   1.582 -    number, and the size of the chunks dispatched. Every rendering
   1.583 -    thread can then ask for a new chunk of frames to render. When no
   1.584 -    more frames are available, None is returned.
   1.585 -
   1.586 -    This dispatching is done in a thread-safe fashion. Threads can
   1.587 -    thus simply call chunk() without having to mess with locks etc.
   1.588 -
   1.589 -    >>> fd = FrameDispatcher(1, 201, 100)
   1.590 -    >>> fd.chunk()
   1.591 -    (1, 100)
   1.592 -    >>> fd.chunk()
   1.593 -    (101, 200)
   1.594 -    >>> fd.chunk()
   1.595 -    (201, 201)
   1.596 -    >>> fd.chunk()
   1.597 -    None
   1.598 -    '''
   1.599 -
   1.600 -    def __init__(self, start, end, chunksize, output_path=None):
   1.601 -        '''Constructor, see class docstring for usage.'''
   1.602 -        
   1.603 -        self.start = start
   1.604 -        self.end = end
   1.605 -        self.chunksize = chunksize
   1.606 -
   1.607 -        if chunksize < 1:
   1.608 -            raise ValueError('Chunk size should be > 0')
   1.609 -        if start > end:
   1.610 -            raise ValueError('Start should be before end')
   1.611 -
   1.612 -        # Figure out the ranges we have to render
   1.613 -        if output_path:
   1.614 -            self.ranges = self._find_ranges(start, end, output_path)
   1.615 -        else:
   1.616 -            self.ranges = [(start, end)]
   1.617 -
   1.618 -        log.debug('Created new %s %i -> %i, %i-size chunks' %
   1.619 -                (self.__class__, start, end, chunksize))
   1.620 -
   1.621 -        self.lock = threading.RLock()
   1.622 -
   1.623 -    def __str__(self):
   1.624 -        return '[%s %i -> %i, %i-size chunks]' % \
   1.625 -                (self.__class__, self.start, self.end, self.chunksize)
   1.626 -
   1.627 -    def __unicode__(self):
   1.628 -        return unicode(str(self))
   1.629 -
   1.630 -    def _find_ranges(self, start, end, output_path):
   1.631 -        '''Returns a list of ranges [(r_start, r_end), ...] of non-existing
   1.632 -        frames in the output path.
   1.633 -        '''
   1.634 -
   1.635 -        log.info('Finding missing frames on %s' % output_path)
   1.636 -
   1.637 -        def framenr(filename):
   1.638 -            basename = os.path.basename(filename)
   1.639 -            (frame, ext) = os.path.splitext(basename)
   1.640 -            try:
   1.641 -                return int(frame, 10)
   1.642 -            except ValueError:
   1.643 -                return None
   1.644 -
   1.645 -        # Start by getting a list of rendered frames.
   1.646 -        files = glob.glob(os.path.join(output_path, '[0-9]*'))
   1.647 -        rendered = set()
   1.648 -        for f in files:
   1.649 -            nr = framenr(f)
   1.650 -            if nr is not None: rendered.add(nr)
   1.651 -        log.debug('Rendered frames: %s' % rendered)
   1.652 -
   1.653 -        # Examine the frame numbers to find holes.
   1.654 -        allframes = set(range(start, end+1))
   1.655 -        missing = sorted(list(allframes - rendered))
   1.656 -        log.debug('Missing frames: %s' % missing)
   1.657 -
   1.658 -        # Now group the missing frames into ranges
   1.659 -        ranges = []
   1.660 -        start = None
   1.661 -        total_missing = len(missing)
   1.662 -        for index, thisframe in enumerate(missing):
   1.663 -            # This frame is the start if we're not in a range already
   1.664 -            if start is None:
   1.665 -                start = thisframe
   1.666 -                log.debug('Start of missing range: %i' % start)
   1.667 -            
   1.668 -            # This frame is the end if this is the last frame, or the next frame
   1.669 -            # is more than one frame away
   1.670 -            if index == total_missing - 1 or missing[index+1] > thisframe+1:
   1.671 -                end = thisframe
   1.672 -                log.debug('End of missing range: %i' % end)
   1.673 -                ranges.append((start, end))
   1.674 -
   1.675 -                # Start over with a new range
   1.676 -                start = None
   1.677 -
   1.678 -        log.info('Missing frames: %s' % ', '.join(['%s-%s' % r for r in ranges]))
   1.679 -        return ranges
   1.680 -
   1.681 -    def chunk(self):
   1.682 -        '''Returns a new chunk of frames.
   1.683 -
   1.684 -        Returns a tuple (start, end) or None if there are no more
   1.685 -        frames to dispatch.
   1.686 -        '''
   1.687 -
   1.688 -        self.lock.acquire()
   1.689 -
   1.690 -        # Maybe we're already out of frames.
   1.691 -        if not self.ranges:
   1.692 -            self.lock.release()
   1.693 -            return None
   1.694 -        
   1.695 -        # Get a new range to render
   1.696 -        start, end = self.ranges.pop(0)
   1.697 -        
   1.698 -        # If the range is larger than the maximum chunk size, we can't use the
   1.699 -        # entire range and have to split it up
   1.700 -        if end - start + 1 > self.chunksize:
   1.701 -            new_start = start + self.chunksize
   1.702 -
   1.703 -            # Put a new range back
   1.704 -            self.ranges.insert(0, (new_start, end))
   1.705 -            end = new_start - 1
   1.706 -
   1.707 -        self.lock.release()
   1.708 -
   1.709 -        log.debug('Ranges left: %s' % self.ranges)
   1.710 -
   1.711 -        return (start, end)
   1.712 -
   1.713 -    def reset(self):
   1.714 -        '''Resets the FrameDispatcher.
   1.715 -
   1.716 -        Subsequent calls to chunk() will return the same values as
   1.717 -        when the FrameDispatcher was just created.
   1.718 -        '''
   1.719 -
   1.720 -        self.lock.acquire()
   1.721 -        self.next_chunk = self.start
   1.722 -        self.lock.release()
   1.723 -
   1.724 -def node_names(config):
   1.725 -    '''Generator, iterates over the node names'''
   1.726 -
   1.727 -    for nodenr in xrange(config['nodes']):
   1.728 -        yield 'node%i' % nodenr
   1.729 -
   1.730 -def load_config():
   1.731 -    '''Loads the configuration file.
   1.732 -    
   1.733 -    Loads the config from /etc/multiblend and ~/.multiblendrc.
   1.734 -    '''
   1.735 -
   1.736 -    conf = ConfigParser.ConfigParser()
   1.737 -    home = os.environ['HOME']
   1.738 -    read = conf.read(['/etc/multiblend', '%s/.multiblendrc' % home])
   1.739 -    if not read:
   1.740 -        print __doc__
   1.741 -        raise SystemExit('No configuration file found. An example '
   1.742 -                'file can be seen above, in the documentation. Place '
   1.743 -                'it in ~/.multiblendrc and alter it to match your '
   1.744 -                'situation.')
   1.745 -
   1.746 -    # Read the main config
   1.747 -    try:
   1.748 -        config = dict(
   1.749 -                outputpath=conf.get('main', 'outputpath'),
   1.750 -                chunks=conf.getint('main', 'chunks'),
   1.751 -                nodes=conf.getint('main', 'nodes'),
   1.752 -                ssh=conf.get('main', 'ssh'),
   1.753 -                scp=conf.get('main', 'scp'),
   1.754 -                nice=conf.getint('main', 'nice'),
   1.755 -            )
   1.756 -    except ConfigParser.NoOptionError, e:
   1.757 -        print __doc__
   1.758 -        print 70*'='
   1.759 -        log.critical('A key is missing in the configuration file: %s' % e)
   1.760 -        raise SystemExit()
   1.761 -
   1.762 -    # Get optional options
   1.763 -    try:
   1.764 -        config['echoing_shell'] = conf.getboolean('main', 'echoing_shell')
   1.765 -    except ConfigParser.NoOptionError, e:
   1.766 -        config['echoing_shell'] = False
   1.767 -    
   1.768 -    # Read the node sections
   1.769 -    for node in node_names(config):
   1.770 -        try:
   1.771 -            config[node] = dict(
   1.772 -                    hostname=conf.get(node, 'hostname'),
   1.773 -                    blender=conf.get(node, 'blender'),
   1.774 -                    workdir=conf.get(node, 'workdir'),
   1.775 -                )
   1.776 -        except ConfigParser.NoOptionError, e:
   1.777 -            print __doc__
   1.778 -            print 70*'='
   1.779 -            log.critical('A key is missing in the configuration file: %s' % e)
   1.780 -            raise SystemExit()
   1.781 -        
   1.782 -        # Get optional option
   1.783 -        try:
   1.784 -            config[node]['echoing_shell'] = conf.getboolean(node, 'echoing_shell')
   1.785 -        except ConfigParser.NoOptionError, e:
   1.786 -            config[node]['echoing_shell'] = config['echoing_shell']
   1.787 -
   1.788 -    return config
   1.789 -
   1.790 -def parse_options():
   1.791 -    '''Parses the commandline options.
   1.792 -
   1.793 -    Returns the options in a dict.
   1.794 -    '''
   1.795 -
   1.796 -    try:
   1.797 -        opts, args = getopt.gnu_getopt(sys.argv[1:], 'b:s:e:TS:E')
   1.798 -    except getopt.GetoptError, e:
   1.799 -        print __doc__
   1.800 -        raise SystemExit(str(e))
   1.801 -
   1.802 -    opts = dict(opts)
   1.803 -    
   1.804 -    for option in ['-b', '-s', '-e']:
   1.805 -        if option not in opts:
   1.806 -            print __doc__
   1.807 -            raise SystemExit('Option %s is required.' % option)
   1.808 -
   1.809 -    if args:
   1.810 -        print __doc__
   1.811 -        raise SystemExit('Unknown argument %s' % args)
   1.812 -
   1.813 -    return opts
   1.814 -
   1.815 -def non_existing_file(path):
   1.816 -    '''Returns a filename which is ensured not to exist (yet) in the
   1.817 -    path.
   1.818 -    '''
   1.819 -
   1.820 -    digest = md5.new('somefile')
   1.821 -    for count in xrange(1000):
   1.822 -        filename = os.path.join(path, digest.hexdigest())
   1.823 -        if not os.path.exists(filename):
   1.824 -            return filename
   1.825 -
   1.826 -        random.jumpahead(count)
   1.827 -        letter = random.choice('abcdefghijklmnopqrstuvwxyz')
   1.828 -        digest.update(letter)
   1.829 -    
   1.830 -    raise RuntimeError('Could not find non-existing name')
   1.831 -
   1.832 -def banner():
   1.833 -    '''Prints the startup banner'''
   1.834 -
   1.835 -    print 60*'='
   1.836 -    print 'Starting Multiblend %s' % __revision__
   1.837 -    print ('Created by %s <%s>' % ( __author__.decode('utf-8'), __email__)).encode('utf-8')
   1.838 -    print __url__
   1.839 -    print 60*'='
   1.840 -    print
   1.841 -
   1.842 -def nodelist(test_nodes = True):
   1.843 -    '''Returns a list of approved nodes.'''
   1.844 -
   1.845 -    # Build list of nodes. Only append approved nodes.
   1.846 -    nodelist = []
   1.847 -    for nodename in node_names(config):
   1.848 -        node = Node(config, nodename)
   1.849 -        if test_nodes:
   1.850 -            if node.test():
   1.851 -                nodelist.append(node)
   1.852 -                log.info('Approved %s' % node)
   1.853 -        else:
   1.854 -            nodelist.append(node)
   1.855 -            log.info('Added %s without testing.' % node)
   1.856 -    
   1.857 -    return nodelist
   1.858 -
   1.859 -def check_options_and_config(options, config, nodes):
   1.860 -    '''Checks the options and config file, to see if there are any
   1.861 -    conflicts.
   1.862 -    '''
   1.863 -
   1.864 -    blenderfile = os.path.abspath(os.path.realpath(options['-b']))
   1.865 -    blenderdir = os.path.dirname(blenderfile)
   1.866 -
   1.867 -    for node in nodes:
   1.868 -        # Check if the localhost workdir is different from the dir the
   1.869 -        # blender file is stored in.
   1.870 -        if not node.hostname == 'localhost':
   1.871 -            continue
   1.872 -
   1.873 -        workdir = os.path.abspath(os.path.realpath(node.workdir))
   1.874 -
   1.875 -        if workdir == blenderdir:
   1.876 -            log.critical('Directory of %s is the same as the '
   1.877 -                    'work directory for the master node. This is '
   1.878 -                    'not allowed.' % options['-b'])
   1.879 -            raise SystemExit()
   1.880 -
   1.881 -def set_scene(config, options):
   1.882 -    '''Sets the 'scene' config key from the -S option'''
   1.883 -
   1.884 -    if '-S' in options:
   1.885 -        config['scene'] = options['-S']
   1.886 -        log.info('Rendering scene %s' % config['scene'])
   1.887 -    else:
   1.888 -        config['scene'] = None
   1.889 -
   1.890 -def create_frame_dispatcher(options, config, node_count):
   1.891 -    '''Creates a frame dispatcher for the given configuration and commandline
   1.892 -    options.
   1.893 -    '''
   1.894 -
   1.895 -    # Get options, calculate chunk size, and create frame dispatcher.
   1.896 -    start = int(options['-s'])
   1.897 -    end = int(options['-e'])
   1.898 -    frames = end - start + 1
   1.899 -    chunksize = max(1, min(config['chunks'], frames / node_count))
   1.900 -
   1.901 -    # Figure out whether we need to check the output path
   1.902 -    fd_output_path = '-E' in options and config['outputpath'] or ''
   1.903 -    return FrameDispatcher(start, end, chunksize, fd_output_path)
   1.904 -
   1.905 -if __name__ == '__main__':
   1.906 -    start_time = datetime.datetime.now()
   1.907 -
   1.908 -    banner()
   1.909 -
   1.910 -    config = load_config()
   1.911 -    options = parse_options()
   1.912 -
   1.913 -    set_scene(config, options)
   1.914 -
   1.915 -    # Get list of nodes.
   1.916 -    nodes = nodelist('-T' not in options)
   1.917 -    if not nodes:
   1.918 -        log.error('No nodes available')
   1.919 -        raise SystemExit()
   1.920 -
   1.921 -    check_options_and_config(options, config, nodes)
   1.922 -    dispatcher = create_frame_dispatcher(options, config, len(nodes))
   1.923 -
   1.924 -    # Start nodes
   1.925 -    for node in nodes:
   1.926 -        node.renderchunks(
   1.927 -            options['-b'],
   1.928 -            dispatcher
   1.929 -        )
   1.930 -    
   1.931 -    try:
   1.932 -        # Give nodes some time to start
   1.933 -        time.sleep(2)
   1.934 -
   1.935 -        # Wait for nodes to finish
   1.936 -        done = False
   1.937 -        while not done:
   1.938 -            done = True
   1.939 -
   1.940 -            # Check the nodes
   1.941 -            for node in nodes:
   1.942 -                # If one node is still rendering, we're not done.
   1.943 -                if node.rendering:
   1.944 -                    done = False
   1.945 -                    break
   1.946 -
   1.947 -            # Wait a while before checking again.
   1.948 -            time.sleep(1)
   1.949 -    except KeyboardInterrupt:
   1.950 -        log.info('Ctrl+C pressed, trying to stop them all. '
   1.951 -                'Please be patient.')
   1.952 -        for node in nodes:
   1.953 -            node.abort()
   1.954 -        
   1.955 -    time.sleep(2)
   1.956 -    log.info('Done multiblendering.')
   1.957 -
   1.958 -    end_time = datetime.datetime.now()
   1.959 -
   1.960 -    log.info('Start time: %s' % start_time)
   1.961 -    log.info('End time  : %s' % end_time)
   1.962 -    log.info('Spent     : %s' % (end_time - start_time))
   1.963 -
   1.964 -# vim:tabstop=4 expandtab foldnestmax=2
     2.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     2.2 +++ b/multiblend/__init__.py	Mon Oct 27 17:09:10 2008 +0100
     2.3 @@ -0,0 +1,961 @@
     2.4 +#!/usr/bin/python
     2.5 +# -*- coding: utf-8 -*-
     2.6 +
     2.7 +'''Multiblend - simple distributed Blender rendering.
     2.8 +
     2.9 +Usage:
    2.10 +    multiblend.py \\
    2.11 +            -b <blenderfile> \\
    2.12 +            -s <startframe> \\
    2.13 +            -e <endframe> \\
    2.14 +            [-T] \\
    2.15 +            [-S Scene name] \\
    2.16 +            [-E]
    2.17 +
    2.18 +    The -T option will cause Multiblend to skip testing the nodes.
    2.19 +    The -S option can denote the scene to render.
    2.20 +    The -E option will cause Multiblend to skip frames that already exist.
    2.21 +
    2.22 +Configuration
    2.23 +=============
    2.24 +
    2.25 +Configuration is read from /etc/multiblend and ~/.multiblendrc. Below
    2.26 +you find an example config file::
    2.27 +
    2.28 +    [main]
    2.29 +    chunks=100
    2.30 +    nodes=2
    2.31 +    outputpath=/home/sybren/documents/blender/output
    2.32 +    ssh=/usr/bin/ssh -Tq
    2.33 +    scp=/usr/bin/scp -q
    2.34 +    echoing_shell=False
    2.35 +    nice=3
    2.36 +
    2.37 +    [node0]
    2.38 +    hostname=localhost
    2.39 +    blender=/home/sybren/blender-2.41-linux-glibc232-py24-i386/blender
    2.40 +    workdir=/home/sybren/tmp
    2.41 +
    2.42 +    [node1]
    2.43 +    hostname=zebra
    2.44 +    blender=/home/sybren/blender-2.41-linux-glibc232-py24-i386/blender
    2.45 +    workdir=/tmp
    2.46 +
    2.47 +The list of nodes should contain the hostnames or IP addresses of the
    2.48 +nodes, the path to the Blender executable, and the working directory.
    2.49 +The number of nodes should be set in the [main] section.
    2.50 +
    2.51 +The 'echoing_shell' option is optional and defaults to False. It can
    2.52 +be set in the [main] section for all the nodes, and can be specified
    2.53 +for specific nodes. First try without this option. If you get the
    2.54 +message that Blender is not executable on nodes where it actually is
    2.55 +executable, try echoing_shell=True.
    2.56 +
    2.57 +WARNING: The working directory should be different from the directory
    2.58 +the blender file is in. Otherwise, you might lose your work.
    2.59 +
    2.60 +Setting up a node
    2.61 +=================
    2.62 +
    2.63 +The node where this script is run, is called the "master node". All
    2.64 +other nodes are just called "node".
    2.65 +
    2.66 +The node should be reachable from the master node using SSH, without
    2.67 +having to type a password. Use 'ssh-keygen' to generate a suitable SSH
    2.68 +key, and use 'ssh-copy-id' to copy the key from the master node to the
    2.69 +other nodes. That generally does the trick. If you want to run a
    2.70 +Blender instance on the master node as well, include a node with
    2.71 +hostname=localhost.
    2.72 +
    2.73 +License
    2.74 +=======
    2.75 +
    2.76 +This software is covered by the Gnu Public License, or GPL. For more
    2.77 +information, see http://www.stuvel.eu/license.
    2.78 +'''
    2.79 +
    2.80 +# Changes:
    2.81 +#
    2.82 +# 2006-05-01, version 1.0
    2.83 +#  - Initial version/home/sstuvel/sadako/presentaties/20060520:
    2.84 +#
    2.85 +# 2006-05-01, version 1.1
    2.86 +#  - The used chunk size is the maximum of the configured chunk size,
    2.87 +#    and the number of frames to render divided by the number of
    2.88 +#    nodes. This tries to ensure all nodes are used, even for a small
    2.89 +#    number of rendered frames.
    2.90 +#  - Logging prints node hostname instead of number.
    2.91 +#  - Pressing Ctrl+C properly stops Blender on all nodes.
    2.92 +#
    2.93 +# 2006-05-10, version 1.2
    2.94 +#  - No longer need NFS, files are copied via scp instead. This does
    2.95 +#    add the requirement that all external files (fonts, textures,
    2.96 +#    etc.) either should be already available on the nodes, or be
    2.97 +#    packed inside the Blender file.
    2.98 +#  - Removed the special hostname LOCAL. In its stead, just use
    2.99 +#    hostname=localhost. This made the code more stable and clean.
   2.100 +#
   2.101 +# 2005-05-29, version 1.3
   2.102 +#  - Start 'sh' immediately on the node. This prevents 'last logged in
   2.103 +#    on ...' messages upon login.
   2.104 +#  - Added echoing_node to the config possibilities.
   2.105 +#  - Added nice level to the config possibilities.
   2.106 +#  - Added possibility to skip node testing.
   2.107 +#  - Fixed chunk size bug when nr of nodes is larger than nr of frames
   2.108 +#    to render.
   2.109 +#  - Fixed encoding bug when stdout is not a terminal.
   2.110 +#  - Added possibility to select the scene to render.
   2.111 +#
   2.112 +# in development, version 1.4
   2.113 +#  - Display timing info after rendering.
   2.114 +#  - Added -E option to skip existing frames.
   2.115 +#
   2.116 +# Ideas for future versions:
   2.117 +#  - Perform check on all nodes, output multiblendrc file with only
   2.118 +#    those nodes that passed the checks.
   2.119 +#  - Display list of missing frames, optionally rendering only those.
   2.120 +
   2.121 +__author__ = 'Sybren Stüvel'
   2.122 +__email__ = 'sybren@stuvel.eu'
   2.123 +__revision__ = '1.4-beta0'
   2.124 +__url__ = 'http://www.stuvel.eu/multiblend'
   2.125 +
   2.126 +import ConfigParser
   2.127 +import datetime
   2.128 +import getopt
   2.129 +import glob
   2.130 +import logging
   2.131 +import md5
   2.132 +import os
   2.133 +import os.path
   2.134 +import popen2
   2.135 +import random
   2.136 +import StringIO
   2.137 +import sys
   2.138 +import threading
   2.139 +import time
   2.140 +
   2.141 +logging.basicConfig()
   2.142 +log = logging.getLogger('multiblend')
   2.143 +log.setLevel('DEBUG' in os.environ and logging.DEBUG or logging.INFO)
   2.144 +
   2.145 +node_loglevel = logging.INFO
   2.146 +
   2.147 +class Node(object):
   2.148 +    '''Representation of a node.'''
   2.149 +
   2.150 +    def __init__(self, config, name):
   2.151 +        self.config = config
   2.152 +        self.name = name
   2.153 +        self.hostname = config[name]['hostname']
   2.154 +        self.blender = config[name]['blender']
   2.155 +        self.workdir = config[name]['workdir']
   2.156 +        self.echoing_shell = config[name]['echoing_shell']
   2.157 +        self.outputpath = config['outputpath']
   2.158 +        self.log = logging.getLogger(self.hostname)
   2.159 +        self.log.setLevel(node_loglevel)
   2.160 +
   2.161 +        self.shell = None
   2.162 +        self.rendering = False
   2.163 +        self.blenderpid = None
   2.164 +    
   2.165 +    def needs_connection(func):
   2.166 +        '''Function decorator.
   2.167 +
   2.168 +        Ensures there is a connected shell before the decorated
   2.169 +        function is called.
   2.170 +        '''
   2.171 +
   2.172 +        def connect_and_call(self, *args, **kwargs):
   2.173 +            '''Connects if self.shell is None, then calls the
   2.174 +            decorated funcion.
   2.175 +            '''
   2.176 +            if self.shell is None:
   2.177 +                self.log.debug('Implicitly connecting to call %s' % func)
   2.178 +                self.connect()
   2.179 +
   2.180 +            return func(self, *args, **kwargs)
   2.181 +
   2.182 +        return connect_and_call
   2.183 +        
   2.184 +    def connect(self):
   2.185 +        '''Connects to this node.
   2.186 +
   2.187 +        Sets self.shell to the connected shell. Use self.send() and
   2.188 +        self.readline() to communicate with host.
   2.189 +
   2.190 +        Returns self.shell.
   2.191 +        '''
   2.192 +
   2.193 +        self.shell = self.create_connection()
   2.194 +        if not self.shell:
   2.195 +            self.log.critical('Unable to create shell!')
   2.196 +            raise RuntimeError('Unable to create shell!')
   2.197 +
   2.198 +        self.send_blenderstarter()
   2.199 +
   2.200 +        return self.shell
   2.201 +
   2.202 +    def __del__(self):
   2.203 +        '''Cleans up the node'''
   2.204 +
   2.205 +        self.log.debug('Cleaning up')
   2.206 +
   2.207 +        if not self.shell:
   2.208 +            # No connection was made, so nothing to clean up
   2.209 +            return
   2.210 +
   2.211 +        try:
   2.212 +                self.remove_blenderstarter()
   2.213 +        except:
   2.214 +            self.log.exception('Error while removing blenderstarter')
   2.215 +
   2.216 +        try:
   2.217 +            self.disconnect()
   2.218 +        except:
   2.219 +            self.log.exception('Error while disconnecting')
   2.220 +
   2.221 +    def create_connection(self):
   2.222 +        '''Connects to this node.
   2.223 +
   2.224 +        Returns a popen2.Popen3 object connected to the shell.
   2.225 +        '''
   2.226 +
   2.227 +        cmd = '%s %s sh' % (config['ssh'], self.hostname)
   2.228 +
   2.229 +        self.log.debug('Starting "%s"' % cmd)
   2.230 +        shell = DebuggingPopen(self.log, cmd)
   2.231 +        if not shell:
   2.232 +            self.log.critical('Unable to create shell!')
   2.233 +            raise RuntimeError('Unable to create shell!')
   2.234 +
   2.235 +        if self.echoing_shell:
   2.236 +            shell.send('stty -echo')
   2.237 +            shell.readline()
   2.238 +        shell.send('export TERM=vt100')
   2.239 +
   2.240 +        return shell
   2.241 +
   2.242 +    def disconnect(self):
   2.243 +        '''Disconnects this node.
   2.244 +        
   2.245 +        Returns the exit status. See DebuggingOpen.wait()
   2.246 +        '''
   2.247 +
   2.248 +        status = self.disconnect_connection(self.shell)
   2.249 +        self.shell = None
   2.250 +
   2.251 +        return status
   2.252 +    
   2.253 +    def disconnect_connection(self, shell):
   2.254 +        '''Disconnects the shell.
   2.255 +
   2.256 +        Returns the exit status. See DebuggingOpen.wait()
   2.257 +        '''
   2.258 +
   2.259 +        shell.send('exit')
   2.260 +        shell.tochild.close()
   2.261 +        shell.fromchild.close()
   2.262 +
   2.263 +        return shell.wait()
   2.264 +
   2.265 +    @needs_connection
   2.266 +    def send(self, line):
   2.267 +        '''Sends 'line' to the shell'''
   2.268 +        return self.shell.send(line)
   2.269 +
   2.270 +    @needs_connection
   2.271 +    def readline(self):
   2.272 +        '''Returns a line from the shell'''
   2.273 +
   2.274 +        return self.shell.readline()
   2.275 +
   2.276 +    @needs_connection
   2.277 +    def test_blender_executable(self):
   2.278 +        '''Tests the existance and executable-ness of blender.'''
   2.279 +
   2.280 +        blender = self.blender
   2.281 +        self.send('[ -x %s ] && echo TRUE || echo FALSE' % blender)
   2.282 +        
   2.283 +        if self.readline().strip() == 'TRUE':
   2.284 +            return True
   2.285 +
   2.286 +        self.log.error('Blender %s is not executable on node %s' % (blender,
   2.287 +            self.name))
   2.288 +        return False
   2.289 +
   2.290 +    def test(self):
   2.291 +        '''Tests this node.
   2.292 +
   2.293 +        Returns True if the node is okay, returns False otherwise.
   2.294 +        '''
   2.295 +
   2.296 +        # No longer an issue, since we no longer use NFS
   2.297 +        #if not self.test_create_file():
   2.298 +        #   return False
   2.299 +
   2.300 +        try:
   2.301 +            if not self.test_blender_executable():
   2.302 +                return False
   2.303 +        except Exception, e:
   2.304 +            self.log.exception('Error while testing node')
   2.305 +            return False
   2.306 +
   2.307 +        self.log.debug('Host %s okay' % self.hostname)
   2.308 +
   2.309 +        return True
   2.310 +
   2.311 +    def __str__(self):
   2.312 +        return '[%s %s]' % (self.name, self.hostname)
   2.313 +    
   2.314 +    def __unicode__(self):
   2.315 +        return unicode(str(self))
   2.316 +
   2.317 +    def blend(self, blenderfile, start, end):
   2.318 +        '''Runs blender on the node.
   2.319 +
   2.320 +        Renders the file 'blenderfile; from frame 'start' to frame
   2.321 +        'end'. Does this in its own thread, returning when the blender
   2.322 +        job is done.
   2.323 +
   2.324 +        The frames are copied to the master node using scp.
   2.325 +        '''
   2.326 +
   2.327 +        self.send_blenderstarter()
   2.328 +
   2.329 +        blenderstarter = os.path.join(self.workdir, 'blenderstarter')
   2.330 +        
   2.331 +        remotefile = os.path.join(self.workdir, blenderfile)
   2.332 +        self.send_file(file(blenderfile), remotefile)
   2.333 +        blenderfile = remotefile
   2.334 +
   2.335 +        self.send('"%s" "%s" "%s" %i %i'
   2.336 +                % (blenderstarter, self.blender, blenderfile, start, end))
   2.337 +
   2.338 +        self.blenderpid = int(self.readline().strip())
   2.339 +
   2.340 +        line = None
   2.341 +        while line != 'DONE':
   2.342 +            line = self.readline().strip()
   2.343 +            
   2.344 +            # Skip empty lines and lines with \r in them - those
   2.345 +            # contain progress information which is annoying on a
   2.346 +            # multi-node system.
   2.347 +            if line and '\r' not in line:
   2.348 +                self.log.debug(line)
   2.349 +
   2.350 +            # Handle saved frames. Those are reported as
   2.351 +            # Saved: /tmp/0001.jpg Time: 00:00.70
   2.352 +            if line.startswith('Saved: '):
   2.353 +                try:
   2.354 +                    self.fetch_saved_file(line.split()[1])
   2.355 +                except RuntimeError, e:
   2.356 +                    self.log.error('Aborted due to RuntimeError: %s' % e)
   2.357 +                    self.rendering = False
   2.358 +                    raise
   2.359 +
   2.360 +        self.blenderpid = None
   2.361 +
   2.362 +    def renderchunks(self, blenderfile, dispatcher):
   2.363 +        '''Renders chunks from the dispatcher in a separate thread.
   2.364 +
   2.365 +        The dispatcher is queried for chunks of frames, and they are
   2.366 +        rendered. If no more frames are available, the thread stops.
   2.367 +
   2.368 +        This function returns immediately after starting the new
   2.369 +        thread.
   2.370 +
   2.371 +        Returns the thread object.
   2.372 +        '''
   2.373 +
   2.374 +        def thread():
   2.375 +            '''The thread that actually does the rendering.'''
   2.376 +
   2.377 +            self.rendering = True
   2.378 +
   2.379 +            while self.rendering:
   2.380 +
   2.381 +                # Get another chunk of frames
   2.382 +                chunk = dispatcher.chunk()
   2.383 +                if chunk is None:
   2.384 +                    self.rendering = False
   2.385 +                    break
   2.386 +
   2.387 +                # Render the frames
   2.388 +                self.log.info('rendering %s' % (chunk, ))
   2.389 +                (start, end) = chunk
   2.390 +
   2.391 +                start_time = time.time()
   2.392 +                self.blend(blenderfile, start, end)
   2.393 +                if time.time() - start_time < 2:
   2.394 +                    self.log.error('Rendering takes too little time, '
   2.395 +                    'something must be wrong. Aborted this node.')
   2.396 +                    self.rendering = False
   2.397 +
   2.398 +            self.log.info("I'm done, no more chunks left for me.")
   2.399 +
   2.400 +        renderthread = threading.Thread(target=thread)
   2.401 +        renderthread.setDaemon(True)
   2.402 +        renderthread.start()
   2.403 +
   2.404 +        return renderthread
   2.405 +
   2.406 +    def abort(self):
   2.407 +        '''Tries to abort this node.
   2.408 +        
   2.409 +        Creates a new SSH connection to the node, and kills the
   2.410 +        blender instance. It won't touch other blender instances
   2.411 +        running on the same host.
   2.412 +        '''
   2.413 +
   2.414 +        self.rendering = False
   2.415 +
   2.416 +        if self.blenderpid:
   2.417 +            killshell = self.create_connection()
   2.418 +            killshell.send('kill %i' % self.blenderpid)
   2.419 +    
   2.420 +    def fetch_saved_file(self, filename):
   2.421 +        '''Fetches the remote file 'filename' from the node, and
   2.422 +        stores it in the local output directory.
   2.423 +
   2.424 +        'filename' should be the filename on the node. It will be
   2.425 +        stored in the outputpath configured in the [main] section of
   2.426 +        the config file.
   2.427 +        '''
   2.428 +
   2.429 +        # Build the scp command
   2.430 +        outputpath = self.outputpath
   2.431 +        cmd = '%s %s:%s %s' % (self.config['scp'], self.hostname, filename, outputpath)
   2.432 +        self.log.debug('Running %s' % cmd)
   2.433 +
   2.434 +        # Copy the frame
   2.435 +        result = os.system(cmd)
   2.436 +
   2.437 +        # Check result of command. Do it thoroughly, because failure
   2.438 +        # to fetch the images defeats the entire rendering process.
   2.439 +        if os.WCOREDUMP(result):
   2.440 +            msg = 'Coredump while fetching %s' % filename
   2.441 +            self.log.critical(msg)
   2.442 +            raise RuntimeError(msg)
   2.443 +        
   2.444 +        if not os.WIFEXITED(result):
   2.445 +            msg = 'scp process did not finish properly while ' \
   2.446 +                    'fetching %s' % filename
   2.447 +            self.log.critical(msg)
   2.448 +            raise RuntimeError(msg)
   2.449 +
   2.450 +        status = os.WEXITSTATUS(result)
   2.451 +        if status > 0:
   2.452 +            msg = 'scp process exited with error status %i while ' \
   2.453 +                    'fetching %s' % (status, filename)
   2.454 +            self.log.critical(msg)
   2.455 +            raise RuntimeError(msg)
   2.456 +    
   2.457 +        self.log.info('Saved %s' % filename)
   2.458 +    
   2.459 +    @needs_connection
   2.460 +    def send_blenderstarter(self):
   2.461 +        '''Sends the blenderstarter script to the client.
   2.462 +
   2.463 +        The script starts blender in the background, reports the PID of
   2.464 +        blender so that we can kill it if required, then waits for Blender
   2.465 +        to finish. After that, DONE is echoed so we know it's done.
   2.466 +        '''
   2.467 +
   2.468 +        starter = '''\
   2.469 +        #!/bin/sh
   2.470 +
   2.471 +        if [ -z "$4" ]; then
   2.472 +            echo "Usage: $0 <blender> <blenderfile> <start> <end>"
   2.473 +            exit 1
   2.474 +        fi
   2.475 +
   2.476 +        nice -n %(nice)i $1 -b "$2" -s $3 -e $4 -a %(scene)s 2>&1 &
   2.477 +        BLENDERPID=$!
   2.478 +
   2.479 +        echo $BLENDERPID
   2.480 +        wait $BLENDERPID
   2.481 +        echo DONE
   2.482 +        '''.replace(8 * ' ', '')
   2.483 +
   2.484 +        # Set scene name option if a scene was passed
   2.485 +        if self.config['scene']:
   2.486 +            scene = '-S %s' % self.config['scene']
   2.487 +        else:
   2.488 +            scene = ''
   2.489 +
   2.490 +        starter = starter % {
   2.491 +                'nice': self.config['nice'],
   2.492 +                'scene': scene
   2.493 +            }
   2.494 +
   2.495 +        path = os.path.join(self.workdir, 'blenderstarter')
   2.496 +        self.send_file(StringIO.StringIO(starter), path)
   2.497 +        self.send('chmod +x %s' % path)
   2.498 +
   2.499 +    @needs_connection
   2.500 +    def send_file(self, fileobj, remotename):
   2.501 +        '''Sends the contents of file-like object 'fileobj' to the
   2.502 +        node, saving it as 'remotename'.
   2.503 +        '''
   2.504 +        
   2.505 +        tmpfilename = non_existing_file('/tmp')
   2.506 +        tmpfile = open(tmpfilename, 'w')
   2.507 +        tmpfile.write(fileobj.read())
   2.508 +        tmpfile.close()
   2.509 +
   2.510 +        cmd = '%s %s %s:%s' % (self.config['scp'], tmpfilename, self.hostname, remotename)
   2.511 +        self.log.debug('Running %s' % cmd)
   2.512 +
   2.513 +        # Copy the file
   2.514 +        result = os.system(cmd)
   2.515 +
   2.516 +        # Check result of command. Do it thoroughly, because failure
   2.517 +        # to fetch the images defeats the entire rendering process.
   2.518 +        if os.WCOREDUMP(result):
   2.519 +            msg = 'Coredump while sending %s' % remotename
   2.520 +            self.log.critical(msg)
   2.521 +            raise RuntimeError(msg)
   2.522 +        
   2.523 +        if not os.WIFEXITED(result):
   2.524 +            msg = 'scp process did not finish properly while ' \
   2.525 +                    'sending %s' % remotename
   2.526 +            self.log.critical(msg)
   2.527 +            raise RuntimeError(msg)
   2.528 +
   2.529 +        status = os.WEXITSTATUS(result)
   2.530 +        if status > 0:
   2.531 +            msg = 'scp process exited with error status %i while ' \
   2.532 +                    'sending %s' % (status, remotename)
   2.533 +            self.log.critical(msg)
   2.534 +            raise RuntimeError(msg)
   2.535 +    
   2.536 +        try:
   2.537 +            os.remove(tmpfilename)
   2.538 +        except IOError, e:
   2.539 +            self.log.warn('Unable to remove temporary file %s: %s' %
   2.540 +                    (tmpfilename, e))
   2.541 +
   2.542 +    @needs_connection
   2.543 +    def remove_blenderstarter(self):
   2.544 +        '''Removes the blender starter script.'''
   2.545 +
   2.546 +        blenderstarter = os.path.join(self.workdir, 'blenderstarter')
   2.547 +        self.send('rm -f %s' % blenderstarter)
   2.548 +
   2.549 +class DebuggingPopen(popen2.Popen3):
   2.550 +    '''Popen4 class, augmented for debugging and easier I/O. Easier
   2.551 +    for this project, anyway ;-)
   2.552 +    '''
   2.553 +
   2.554 +    def __init__(self, log, *args, **kwargs):
   2.555 +        self.log = log
   2.556 +        popen2.Popen3.__init__(self, *args, **kwargs)
   2.557 +
   2.558 +    def send(self, line):
   2.559 +        '''Sends a line of text to the process.
   2.560 +
   2.561 +        A newline is appended for your convenience.
   2.562 +        '''
   2.563 +
   2.564 +        self.log.debug('Sending: %s' % line)
   2.565 +        print >> self.tochild, line
   2.566 +        self.tochild.flush()
   2.567 +    
   2.568 +    def readline(self):
   2.569 +        '''Returns a line of text from the client.'''
   2.570 +
   2.571 +        line = self.fromchild.readline()
   2.572 +        stripped = line.strip()
   2.573 +        if stripped:
   2.574 +            self.log.debug('Received: %s' % stripped)
   2.575 +
   2.576 +        return line
   2.577 +
   2.578 +class FrameDispatcher(object):
   2.579 +    '''Frame dispatcher for render nodes.
   2.580 +
   2.581 +    The dispatcher is initialized with a starting and ending frame
   2.582 +    number, and the size of the chunks dispatched. Every rendering
   2.583 +    thread can then ask for a new chunk of frames to render. When no
   2.584 +    more frames are available, None is returned.
   2.585 +
   2.586 +    This dispatching is done in a thread-safe fashion. Threads can
   2.587 +    thus simply call chunk() without having to mess with locks etc.
   2.588 +
   2.589 +    >>> fd = FrameDispatcher(1, 201, 100)
   2.590 +    >>> fd.chunk()
   2.591 +    (1, 100)
   2.592 +    >>> fd.chunk()
   2.593 +    (101, 200)
   2.594 +    >>> fd.chunk()
   2.595 +    (201, 201)
   2.596 +    >>> fd.chunk()
   2.597 +    None
   2.598 +    '''
   2.599 +
   2.600 +    def __init__(self, start, end, chunksize, output_path=None):
   2.601 +        '''Constructor, see class docstring for usage.'''
   2.602 +        
   2.603 +        self.start = start
   2.604 +        self.end = end
   2.605 +        self.chunksize = chunksize
   2.606 +
   2.607 +        if chunksize < 1:
   2.608 +            raise ValueError('Chunk size should be > 0')
   2.609 +        if start > end:
   2.610 +            raise ValueError('Start should be before end')
   2.611 +
   2.612 +        # Figure out the ranges we have to render
   2.613 +        if output_path:
   2.614 +            self.ranges = self._find_ranges(start, end, output_path)
   2.615 +        else:
   2.616 +            self.ranges = [(start, end)]
   2.617 +
   2.618 +        log.debug('Created new %s %i -> %i, %i-size chunks' %
   2.619 +                (self.__class__, start, end, chunksize))
   2.620 +
   2.621 +        self.lock = threading.RLock()
   2.622 +
   2.623 +    def __str__(self):
   2.624 +        return '[%s %i -> %i, %i-size chunks]' % \
   2.625 +                (self.__class__, self.start, self.end, self.chunksize)
   2.626 +
   2.627 +    def __unicode__(self):
   2.628 +        return unicode(str(self))
   2.629 +
   2.630 +    def _find_ranges(self, start, end, output_path):
   2.631 +        '''Returns a list of ranges [(r_start, r_end), ...] of non-existing
   2.632 +        frames in the output path.
   2.633 +        '''
   2.634 +
   2.635 +        log.info('Finding missing frames on %s' % output_path)
   2.636 +
   2.637 +        def framenr(filename):
   2.638 +            basename = os.path.basename(filename)
   2.639 +            (frame, ext) = os.path.splitext(basename)
   2.640 +            try:
   2.641 +                return int(frame, 10)
   2.642 +            except ValueError:
   2.643 +                return None
   2.644 +
   2.645 +        # Start by getting a list of rendered frames.
   2.646 +        files = glob.glob(os.path.join(output_path, '[0-9]*'))
   2.647 +        rendered = set()
   2.648 +        for f in files:
   2.649 +            nr = framenr(f)
   2.650 +            if nr is not None: rendered.add(nr)
   2.651 +        log.debug('Rendered frames: %s' % rendered)
   2.652 +
   2.653 +        # Examine the frame numbers to find holes.
   2.654 +        allframes = set(range(start, end+1))
   2.655 +        missing = sorted(list(allframes - rendered))
   2.656 +        log.debug('Missing frames: %s' % missing)
   2.657 +
   2.658 +        # Now group the missing frames into ranges
   2.659 +        ranges = []
   2.660 +        start = None
   2.661 +        total_missing = len(missing)
   2.662 +        for index, thisframe in enumerate(missing):
   2.663 +            # This frame is the start if we're not in a range already
   2.664 +            if start is None:
   2.665 +                start = thisframe
   2.666 +                log.debug('Start of missing range: %i' % start)
   2.667 +            
   2.668 +            # This frame is the end if this is the last frame, or the next frame
   2.669 +            # is more than one frame away
   2.670 +            if index == total_missing - 1 or missing[index+1] > thisframe+1:
   2.671 +                end = thisframe
   2.672 +                log.debug('End of missing range: %i' % end)
   2.673 +                ranges.append((start, end))
   2.674 +
   2.675 +                # Start over with a new range
   2.676 +                start = None
   2.677 +
   2.678 +        log.info('Missing frames: %s' % ', '.join(['%s-%s' % r for r in ranges]))
   2.679 +        return ranges
   2.680 +
   2.681 +    def chunk(self):
   2.682 +        '''Returns a new chunk of frames.
   2.683 +
   2.684 +        Returns a tuple (start, end) or None if there are no more
   2.685 +        frames to dispatch.
   2.686 +        '''
   2.687 +
   2.688 +        self.lock.acquire()
   2.689 +
   2.690 +        # Maybe we're already out of frames.
   2.691 +        if not self.ranges:
   2.692 +            self.lock.release()
   2.693 +            return None
   2.694 +        
   2.695 +        # Get a new range to render
   2.696 +        start, end = self.ranges.pop(0)
   2.697 +        
   2.698 +        # If the range is larger than the maximum chunk size, we can't use the
   2.699 +        # entire range and have to split it up
   2.700 +        if end - start + 1 > self.chunksize:
   2.701 +            new_start = start + self.chunksize
   2.702 +
   2.703 +            # Put a new range back
   2.704 +            self.ranges.insert(0, (new_start, end))
   2.705 +            end = new_start - 1
   2.706 +
   2.707 +        self.lock.release()
   2.708 +
   2.709 +        log.debug('Ranges left: %s' % self.ranges)
   2.710 +
   2.711 +        return (start, end)
   2.712 +
   2.713 +    def reset(self):
   2.714 +        '''Resets the FrameDispatcher.
   2.715 +
   2.716 +        Subsequent calls to chunk() will return the same values as
   2.717 +        when the FrameDispatcher was just created.
   2.718 +        '''
   2.719 +
   2.720 +        self.lock.acquire()
   2.721 +        self.next_chunk = self.start
   2.722 +        self.lock.release()
   2.723 +
   2.724 +def node_names(config):
   2.725 +    '''Generator, iterates over the node names'''
   2.726 +
   2.727 +    for nodenr in xrange(config['nodes']):
   2.728 +        yield 'node%i' % nodenr
   2.729 +
   2.730 +def load_config():
   2.731 +    '''Loads the configuration file.
   2.732 +    
   2.733 +    Loads the config from /etc/multiblend and ~/.multiblendrc.
   2.734 +    '''
   2.735 +
   2.736 +    conf = ConfigParser.ConfigParser()
   2.737 +    home = os.environ['HOME']
   2.738 +    read = conf.read(['/etc/multiblend', '%s/.multiblendrc' % home])
   2.739 +    if not read:
   2.740 +        print __doc__
   2.741 +        raise SystemExit('No configuration file found. An example '
   2.742 +                'file can be seen above, in the documentation. Place '
   2.743 +                'it in ~/.multiblendrc and alter it to match your '
   2.744 +                'situation.')
   2.745 +
   2.746 +    # Read the main config
   2.747 +    try:
   2.748 +        config = dict(
   2.749 +                outputpath=conf.get('main', 'outputpath'),
   2.750 +                chunks=conf.getint('main', 'chunks'),
   2.751 +                nodes=conf.getint('main', 'nodes'),
   2.752 +                ssh=conf.get('main', 'ssh'),
   2.753 +                scp=conf.get('main', 'scp'),
   2.754 +                nice=conf.getint('main', 'nice'),
   2.755 +            )
   2.756 +    except ConfigParser.NoOptionError, e:
   2.757 +        print __doc__
   2.758 +        print 70*'='
   2.759 +        log.critical('A key is missing in the configuration file: %s' % e)
   2.760 +        raise SystemExit()
   2.761 +
   2.762 +    # Get optional options
   2.763 +    try:
   2.764 +        config['echoing_shell'] = conf.getboolean('main', 'echoing_shell')
   2.765 +    except ConfigParser.NoOptionError, e:
   2.766 +        config['echoing_shell'] = False
   2.767 +    
   2.768 +    # Read the node sections
   2.769 +    for node in node_names(config):
   2.770 +        try:
   2.771 +            config[node] = dict(
   2.772 +                    hostname=conf.get(node, 'hostname'),
   2.773 +                    blender=conf.get(node, 'blender'),
   2.774 +                    workdir=conf.get(node, 'workdir'),
   2.775 +                )
   2.776 +        except ConfigParser.NoOptionError, e:
   2.777 +            print __doc__
   2.778 +            print 70*'='
   2.779 +            log.critical('A key is missing in the configuration file: %s' % e)
   2.780 +            raise SystemExit()
   2.781 +        
   2.782 +        # Get optional option
   2.783 +        try:
   2.784 +            config[node]['echoing_shell'] = conf.getboolean(node, 'echoing_shell')
   2.785 +        except ConfigParser.NoOptionError, e:
   2.786 +            config[node]['echoing_shell'] = config['echoing_shell']
   2.787 +
   2.788 +    return config
   2.789 +
   2.790 +def parse_options():
   2.791 +    '''Parses the commandline options.
   2.792 +
   2.793 +    Returns the options in a dict.
   2.794 +    '''
   2.795 +
   2.796 +    try:
   2.797 +        opts, args = getopt.gnu_getopt(sys.argv[1:], 'b:s:e:TS:E')
   2.798 +    except getopt.GetoptError, e:
   2.799 +        print __doc__
   2.800 +        raise SystemExit(str(e))
   2.801 +
   2.802 +    opts = dict(opts)
   2.803 +    
   2.804 +    for option in ['-b', '-s', '-e']:
   2.805 +        if option not in opts:
   2.806 +            print __doc__
   2.807 +            raise SystemExit('Option %s is required.' % option)
   2.808 +
   2.809 +    if args:
   2.810 +        print __doc__
   2.811 +        raise SystemExit('Unknown argument %s' % args)
   2.812 +
   2.813 +    return opts
   2.814 +
   2.815 +def non_existing_file(path):
   2.816 +    '''Returns a filename which is ensured not to exist (yet) in the
   2.817 +    path.
   2.818 +    '''
   2.819 +
   2.820 +    digest = md5.new('somefile')
   2.821 +    for count in xrange(1000):
   2.822 +        filename = os.path.join(path, digest.hexdigest())
   2.823 +        if not os.path.exists(filename):
   2.824 +            return filename
   2.825 +
   2.826 +        random.jumpahead(count)
   2.827 +        letter = random.choice('abcdefghijklmnopqrstuvwxyz')
   2.828 +        digest.update(letter)
   2.829 +    
   2.830 +    raise RuntimeError('Could not find non-existing name')
   2.831 +
   2.832 +def banner():
   2.833 +    '''Prints the startup banner'''
   2.834 +
   2.835 +    print 60*'='
   2.836 +    print 'Starting Multiblend %s' % __revision__
   2.837 +    print ('Created by %s <%s>' % ( __author__.decode('utf-8'), __email__)).encode('utf-8')
   2.838 +    print __url__
   2.839 +    print 60*'='
   2.840 +    print
   2.841 +
   2.842 +def nodelist(test_nodes = True):
   2.843 +    '''Returns a list of approved nodes.'''
   2.844 +
   2.845 +    # Build list of nodes. Only append approved nodes.
   2.846 +    nodelist = []
   2.847 +    for nodename in node_names(config):
   2.848 +        node = Node(config, nodename)
   2.849 +        if test_nodes:
   2.850 +            if node.test():
   2.851 +                nodelist.append(node)
   2.852 +                log.info('Approved %s' % node)
   2.853 +        else:
   2.854 +            nodelist.append(node)
   2.855 +            log.info('Added %s without testing.' % node)
   2.856 +    
   2.857 +    return nodelist
   2.858 +
   2.859 +def check_options_and_config(options, config, nodes):
   2.860 +    '''Checks the options and config file, to see if there are any
   2.861 +    conflicts.
   2.862 +    '''
   2.863 +
   2.864 +    blenderfile = os.path.abspath(os.path.realpath(options['-b']))
   2.865 +    blenderdir = os.path.dirname(blenderfile)
   2.866 +
   2.867 +    for node in nodes:
   2.868 +        # Check if the localhost workdir is different from the dir the
   2.869 +        # blender file is stored in.
   2.870 +        if not node.hostname == 'localhost':
   2.871 +            continue
   2.872 +
   2.873 +        workdir = os.path.abspath(os.path.realpath(node.workdir))
   2.874 +
   2.875 +        if workdir == blenderdir:
   2.876 +            log.critical('Directory of %s is the same as the '
   2.877 +                    'work directory for the master node. This is '
   2.878 +                    'not allowed.' % options['-b'])
   2.879 +            raise SystemExit()
   2.880 +
   2.881 +def set_scene(config, options):
   2.882 +    '''Sets the 'scene' config key from the -S option'''
   2.883 +
   2.884 +    if '-S' in options:
   2.885 +        config['scene'] = options['-S']
   2.886 +        log.info('Rendering scene %s' % config['scene'])
   2.887 +    else:
   2.888 +        config['scene'] = None
   2.889 +
   2.890 +def create_frame_dispatcher(options, config, node_count):
   2.891 +    '''Creates a frame dispatcher for the given configuration and commandline
   2.892 +    options.
   2.893 +    '''
   2.894 +
   2.895 +    # Get options, calculate chunk size, and create frame dispatcher.
   2.896 +    start = int(options['-s'])
   2.897 +    end = int(options['-e'])
   2.898 +    frames = end - start + 1
   2.899 +    chunksize = max(1, min(config['chunks'], frames / node_count))
   2.900 +
   2.901 +    # Figure out whether we need to check the output path
   2.902 +    fd_output_path = '-E' in options and config['outputpath'] or ''
   2.903 +    return FrameDispatcher(start, end, chunksize, fd_output_path)
   2.904 +
   2.905 +if __name__ == '__main__':
   2.906 +    start_time = datetime.datetime.now()
   2.907 +
   2.908 +    banner()
   2.909 +
   2.910 +    config = load_config()
   2.911 +    options = parse_options()
   2.912 +
   2.913 +    set_scene(config, options)
   2.914 +
   2.915 +    # Get list of nodes.
   2.916 +    nodes = nodelist('-T' not in options)
   2.917 +    if not nodes:
   2.918 +        log.error('No nodes available')
   2.919 +        raise SystemExit()
   2.920 +
   2.921 +    check_options_and_config(options, config, nodes)
   2.922 +    dispatcher = create_frame_dispatcher(options, config, len(nodes))
   2.923 +
   2.924 +    # Start nodes
   2.925 +    for node in nodes:
   2.926 +        node.renderchunks(
   2.927 +            options['-b'],
   2.928 +            dispatcher
   2.929 +        )
   2.930 +    
   2.931 +    try:
   2.932 +        # Give nodes some time to start
   2.933 +        time.sleep(2)
   2.934 +
   2.935 +        # Wait for nodes to finish
   2.936 +        done = False
   2.937 +        while not done:
   2.938 +            done = True
   2.939 +
   2.940 +            # Check the nodes
   2.941 +            for node in nodes:
   2.942 +                # If one node is still rendering, we're not done.
   2.943 +                if node.rendering:
   2.944 +                    done = False
   2.945 +                    break
   2.946 +
   2.947 +            # Wait a while before checking again.
   2.948 +            time.sleep(1)
   2.949 +    except KeyboardInterrupt:
   2.950 +        log.info('Ctrl+C pressed, trying to stop them all. '
   2.951 +                'Please be patient.')
   2.952 +        for node in nodes:
   2.953 +            node.abort()
   2.954 +        
   2.955 +    time.sleep(2)
   2.956 +    log.info('Done multiblendering.')
   2.957 +
   2.958 +    end_time = datetime.datetime.now()
   2.959 +
   2.960 +    log.info('Start time: %s' % start_time)
   2.961 +    log.info('End time  : %s' % end_time)
   2.962 +    log.info('Spent     : %s' % (end_time - start_time))
   2.963 +
   2.964 +# vim:tabstop=4 expandtab foldnestmax=2