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
