├── README.md └── spritify.py /README.md: -------------------------------------------------------------------------------- 1 | ### About 2 | 3 | This [Blender](https://blender.org) add-on will compile all rendered frames from your animation into a very nice and configurable sprite sheet, that's usable for sprite-based video games and even in modern cool websites. 4 | It can also generate animated gifs, allowing you to easily showcase and preview animations without needing to use any code at all, useful stuff. 5 | 6 | It was originally developed for use in the amazing [Ancient Beast](https://AncientBeast.com) eSport game project but sharing is caring! 🌟 7 | 8 | ### Requirements 9 | 10 | This add-on requires that you have [ImageMagick](https://imagemagick.org) installed on your computer and the montage command is in your system path. 11 | It also assumes that you're rendering a sequence of still frames. Video renders will not work for this. 12 | 13 | ### Installation 14 | 15 | 1. Go to `Edit` / `Preferences` / `Add-ons` and click `Install...` 16 | 2. Use the `File Browser` to find `spritify.py` on your hard drive 17 | 3. Double-click or select the file and click `Install Add on...` 18 | 4. Use the `Add-ons browser` to find and enable `Spritify` (from `Render` category) 19 | 20 | ### Usage 21 | 22 | There is a Spritify panel in Render Properties where you configure some of the 23 | attributes of the sprite sheet as well as enable or disable the ability to 24 | automatically generate a sprite sheet each time an animation render completes. 25 | -------------------------------------------------------------------------------- /spritify.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Convert rendered frames into a sprite sheet once render is complete. 3 | ''' 4 | # ***** BEGIN GPL LICENSE BLOCK ***** 5 | # 6 | # 7 | # This program is free software; you can redistribute it and/or 8 | # modify it under the terms of the GNU General Public License 9 | # as published by the Free Software Foundation; either version 2 10 | # of the License, or (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software Foundation, 19 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 20 | # 21 | # ***** END GPL LICENCE BLOCK ***** 22 | import bpy 23 | import os 24 | import subprocess 25 | import math 26 | from bpy.app.handlers import persistent 27 | import sys 28 | import shutil 29 | 30 | bl_info = { 31 | "name": "Spritify", 32 | "author": "Jason van Gumster (Fweeb)", 33 | "version": (0, 6, 4), 34 | "blender": (2, 80, 0), 35 | "location": "Render > Spritify", 36 | "description": ("Converts rendered frames into a sprite sheet" 37 | " once render is complete"), 38 | "warning": "Requires ImageMagick", 39 | "wiki_url": ("http://wiki.blender.org/index.php" 40 | "?title=Extensions:2.6/Py/Scripts/Render/Spritify"), 41 | "tracker_url": "https://github.com/FreezingMoon/Spritify/issues", 42 | "category": "Render", 43 | } 44 | 45 | 46 | class SpriteSheetProperties(bpy.types.PropertyGroup): 47 | filepath: bpy.props.StringProperty( 48 | name="Sprite Sheet Filepath", 49 | description="Save location for sprite sheet (should be PNG format)", 50 | subtype='FILE_PATH', 51 | default=os.path.join( 52 | bpy.context.preferences.filepaths.render_output_directory, 53 | "sprites.png" 54 | ), 55 | ) 56 | imagemagick_path: bpy.props.StringProperty( 57 | name="Imagemagick Path", 58 | description=("Path where the Imagemagick binaries can be found" 59 | " (only on Linux and macOS)"), 60 | subtype='FILE_PATH', 61 | default='/usr/bin', 62 | ) 63 | quality: bpy.props.IntProperty( 64 | name="Quality", 65 | description="Quality setting for sprite sheet image", 66 | subtype='PERCENTAGE', 67 | max=100, 68 | default=100, 69 | ) 70 | is_rows: bpy.props.EnumProperty( 71 | name="Rows/Columns", 72 | description="Choose if tiles will be arranged by rows or columns", 73 | items=(('ROWS', "Rows", "Rows"), ('COLUMNS', "Columns", "Columns")), 74 | default='ROWS', 75 | ) 76 | tiles: bpy.props.IntProperty( 77 | name="Tiles", 78 | description="Number of tiles in the chosen direction (rows or columns)", 79 | default=8, 80 | ) 81 | files: bpy.props.IntProperty( 82 | name="File count", 83 | description="Number of files to split sheet into", 84 | default=1, 85 | ) 86 | offset_x: bpy.props.IntProperty( 87 | name="Offset X", 88 | description="Horizontal offset between tiles (in pixels)", 89 | default=2, 90 | ) 91 | offset_y: bpy.props.IntProperty( 92 | name="Offset Y", 93 | description="Vertical offset between tiles (in pixels)", 94 | default=2, 95 | ) 96 | bg_color: bpy.props.FloatVectorProperty( 97 | name="Background Color", 98 | description="Fill color for sprite backgrounds", 99 | subtype='COLOR', 100 | size=4, 101 | min=0.0, 102 | max=1.0, 103 | default=(0.0, 0.0, 0.0, 0.0), 104 | ) 105 | auto_sprite: bpy.props.BoolProperty( 106 | name="AutoSpritify", 107 | description=("Automatically create a spritesheet" 108 | " when rendering is complete"), 109 | default=True, 110 | ) 111 | auto_gif: bpy.props.BoolProperty( 112 | name="AutoGIF", 113 | description=("Automatically create an animated GIF" 114 | " when rendering is complete"), 115 | default=True, 116 | ) 117 | support_multiview: bpy.props.BoolProperty( 118 | name="Support Multiviews", 119 | description=("Render multiple spritesheets based on multiview" 120 | " suffixes if stereoscopy/multiview is configured"), 121 | default=True, 122 | ) 123 | 124 | 125 | def find_bin_path_windows(): 126 | import winreg 127 | 128 | REG_PATH = "SOFTWARE\\ImageMagick\\Current" 129 | 130 | try: 131 | registry_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, REG_PATH, 0, 132 | winreg.KEY_READ) 133 | value, regtype = winreg.QueryValueEx(registry_key, "BinPath") 134 | winreg.CloseKey(registry_key) 135 | 136 | except WindowsError: 137 | return None 138 | 139 | print(value) 140 | return value 141 | 142 | 143 | def show_message(operator, message, title="Spritify", icon='INFO'): 144 | def draw(self, context): 145 | self.layout.label(text=message) 146 | print("[{}]".format(title), message, file=sys.stderr) 147 | if operator is not None: 148 | operator.report({'INFO'}, "[{}] {}".format(title, message)) 149 | else: 150 | print("[{}] operator is None in show_message." 151 | "".format(title), file=sys.stderr) 152 | ''' 153 | try: 154 | bpy.context.window_manager.popup_menu(draw, title=title, icon=icon) 155 | # ^ crashes, but does *not* raise exception! 156 | # The reason seems to be bad pointer (access violation) 157 | # according to 159 | except Exception: 160 | print(exn, file=sys.stderr) 161 | ''' 162 | 163 | @persistent 164 | def spritify(scene, operator): 165 | if scene.spritesheet.auto_sprite: 166 | print("[Spritify] Making sprite sheet") 167 | # Remove existing spritesheet if it's already there 168 | if os.path.exists(bpy.path.abspath(scene.spritesheet.filepath)): 169 | os.remove(bpy.path.abspath(scene.spritesheet.filepath)) 170 | 171 | if scene.spritesheet.is_rows == 'ROWS': 172 | tile_setting = str(scene.spritesheet.tiles) + "x" 173 | else: 174 | tile_setting = "x" + str(scene.spritesheet.tiles) 175 | 176 | suffixes = [] 177 | 178 | if (scene.spritesheet.support_multiview and scene.render.use_multiview 179 | and scene.render.views_format == 'MULTIVIEW'): 180 | for view in scene.render.views: 181 | suffixes.append(view.file_suffix) 182 | else: 183 | suffixes.append('') 184 | destination = None 185 | for suffix in suffixes: 186 | print('[Spritify] Preloading suffix "{}"'.format(suffix)) 187 | # Preload images 188 | images = [] 189 | render_dir = bpy.path.abspath(scene.render.filepath) 190 | for dirname, dirnames, filenames in os.walk(render_dir): 191 | for filename in sorted(filenames): 192 | if filename.endswith("%s.png" % suffix): 193 | images.append(os.path.join(dirname, filename)) 194 | 195 | # Calc number of images per file 196 | per_file = math.ceil(len(images) / scene.spritesheet.files) 197 | offset = 0 198 | index = 0 199 | 200 | if len(images) < 1: 201 | raise FileNotFoundError( 202 | 'There are 0 images in "{}".' 203 | '\n\nGenerate Sprite Sheet requires:' 204 | '\n1. Output Properties: Set format to PNG' 205 | '\n2. Render, Render Animation.' 206 | ''.format(render_dir) 207 | ) 208 | print("[Spritify] Processing {} image(s)".format(len(images))) 209 | # While is faster than for+range 210 | if per_file < 1: 211 | raise ValueError("The offset cannot be less than 1.") 212 | # ^ Prevent an infinite loop. 213 | 214 | while offset < len(images): 215 | current_images = images[offset:offset+per_file] 216 | filename = scene.spritesheet.filepath 217 | if scene.spritesheet.files > 1: 218 | filename = "%s-%d-%s%s" % (scene.spritesheet.filepath[:-4], 219 | index, suffix, 220 | scene.spritesheet.filepath[-4:]) 221 | else: 222 | filename = "%s%s%s" % (scene.spritesheet.filepath[:-4], 223 | suffix, 224 | scene.spritesheet.filepath[-4:]) 225 | print('[Spritify] processing "{}"'.format(filename)) 226 | bin_path = scene.spritesheet.imagemagick_path 227 | 228 | if os.name == "nt": 229 | bin_path = find_bin_path_windows() 230 | 231 | width = (scene.render.resolution_x 232 | * scene.render.resolution_percentage / 100) 233 | height = (scene.render.resolution_y 234 | * scene.render.resolution_percentage / 100) 235 | 236 | if scene.render.use_crop_to_border: 237 | width = (scene.render.border_max_x 238 | * width - scene.render.border_min_x * width) 239 | height = (scene.render.border_max_y 240 | * height - scene.render.border_min_y * height) 241 | background = ( 242 | "rgba({},{},{},{})".format( 243 | str(scene.spritesheet.bg_color[0] * 100), 244 | str(scene.spritesheet.bg_color[1] * 100), 245 | str(scene.spritesheet.bg_color[2] * 100), 246 | str(scene.spritesheet.bg_color[3] * 100), 247 | ) 248 | ) 249 | geometry = "{}x{}+{}+{}".format( 250 | width, 251 | height, 252 | scene.spritesheet.offset_x, 253 | scene.spritesheet.offset_y 254 | ) 255 | montage_path = os.path.join(bin_path, "montage") 256 | if not os.path.isfile(montage_path): 257 | raise FileNotFoundError( 258 | 'The executable "{}" does not exist.' 259 | '\nTIP: Make sure ImageMagick is installed and that' 260 | ' the bin directory is correct' 261 | ' in Render Properties, Spritify.' 262 | ''.format(montage_path) 263 | ) 264 | depth = "8" 265 | destination = bpy.path.abspath(filename) 266 | montage_call = [ 267 | montage_path, 268 | "-depth", 269 | depth, 270 | "-tile", 271 | tile_setting, 272 | "-geometry", 273 | geometry, 274 | "-background", 275 | background, 276 | "-quality", 277 | str(scene.spritesheet.quality), 278 | ] # See extend below for source, & append for destination 279 | montage_call.extend(current_images) 280 | montage_call.append(destination) 281 | 282 | result = subprocess.call(montage_call) 283 | # print("[Spritify]", montage_call, "result:", result) 284 | # ^ still 1 even if succeeds for some reason 285 | offset += per_file 286 | index += 1 287 | if os.path.isfile(destination): 288 | msg = ('Spritify finished writing auto_sprite "{}".' 289 | ''.format(destination)) 290 | show_message(operator, msg, title="spritify") 291 | return {'message': msg} 292 | else: 293 | msg = ('Spritify failed to write auto_sprite "{}".' 294 | ''.format(destination)) 295 | show_message(operator, msg, icon="ERROR", title="spritify") 296 | return {'error': msg} 297 | 298 | 299 | 300 | @persistent 301 | def gifify(scene, operator): 302 | if scene.spritesheet.auto_gif: 303 | print("[Spritify] Generating animated GIF") 304 | # Remove existing animated GIF if it's already there 305 | # (uses the same path as the spritesheet) 306 | if os.path.exists( 307 | bpy.path.abspath(scene.spritesheet.filepath[:-3] + "gif") 308 | ): 309 | os.remove(bpy.path.abspath(scene.spritesheet.filepath[:-3] + "gif")) 310 | 311 | # If windows, try and find binary 312 | converter_path = "%s/convert" % scene.spritesheet.imagemagick_path 313 | # ^ formerly convert_path which was ambiguous (See new try_path 314 | # for temp image files). 315 | 316 | if os.name == "nt": 317 | bin_path = find_bin_path_windows() 318 | 319 | if bin_path: 320 | converter_path = os.path.join(bin_path, "convert") 321 | 322 | if not os.path.isfile(converter_path): 323 | raise FileNotFoundError( 324 | 'The executable "{}" does not exist.' 325 | '\n\nTIP: Make sure ImageMagick is installed and that' 326 | ' the bin directory is correct in Render Properties, Spritify.' 327 | ''.format(converter_path) 328 | ) 329 | 330 | mixed_files_path, name_partial = os.path.split(scene.render.filepath) 331 | # ^ It is a partial name--It may have numbers after it. 332 | # partial, dotext = os.path.splitext(name_partial) 333 | found_any_file = None 334 | source = bpy.path.abspath(scene.render.filepath) + "*" 335 | # ^ The scene render filepath becomes part of each png 336 | # frame filename. 337 | convert_tmp_dir = os.path.join(mixed_files_path, "spritify") 338 | if os.path.isdir(convert_tmp_dir): 339 | shutil.rmtree(convert_tmp_dir) 340 | os.makedirs(convert_tmp_dir) 341 | if os.path.isdir(mixed_files_path): 342 | for sub in os.listdir(mixed_files_path): 343 | if sub.startswith("."): 344 | continue 345 | if sub.lower().endswith(".tmp"): 346 | continue 347 | if sub.lower().endswith(".txt"): 348 | # such as {blendfilename.splitext()[0]}.crash.txt 349 | continue 350 | if not sub.lower().endswith(".png"): 351 | # Allow *only* png animation frames! 352 | continue 353 | sub_path = os.path.join(mixed_files_path, sub) 354 | if not os.path.isfile(sub_path): 355 | continue 356 | found_any_file = sub_path 357 | new_path = os.path.join(convert_tmp_dir, sub) 358 | shutil.move(sub_path, new_path) 359 | break 360 | 361 | if found_any_file is None: 362 | caveat = "" 363 | raise FileNotFoundError( 364 | 'There are no "{}" PNG files.' 365 | '\n\nGenerating GIF requires:' 366 | '\n1. Output Properties: Set format to PNG' 367 | '\n2. Render, Render Animation.' 368 | '\n3. Generate Sprite Sheet' 369 | ''.format(source) 370 | ) 371 | delay = "1x" + str(scene.render.fps) 372 | dispose = "background" 373 | loop = "0" 374 | destination = bpy.path.abspath(scene.spritesheet.filepath[:-3] + "gif") 375 | if os.path.isfile(destination): 376 | show_message(operator, 'Overwriting "{}"'.format(destination)) 377 | os.remove(destination) 378 | if not os.path.isdir(convert_tmp_dir): 379 | raise FileNotFoundError( 380 | 'convert_tmp_dir does not exist: "{}"' 381 | ''.format(convert_tmp_dir) 382 | ) 383 | subprocess.call([ 384 | convert_tmp_dir, 385 | "-delay", 386 | delay, 387 | "-dispose", 388 | dispose, 389 | "-loop", 390 | loop, 391 | source, 392 | # FIXME: ^ scene.render.filepath assumes the files in the 393 | # render path are only for the rendered animation 394 | destination 395 | ]) 396 | if os.path.isfile(destination): 397 | msg = 'Spritify finished writing auto_gif "{}".'.format(destination) 398 | show_message(operator, msg, title="Spritify gifify") 399 | return {'message': msg} 400 | else: 401 | msg = 'Spritify failed to create auto_gif "{}".'.format(destination) 402 | show_message(operator, msg, icon="ERROR", title="Spritify gifify") 403 | return {'error': msg} 404 | 405 | 406 | class SpritifyOperator(bpy.types.Operator): 407 | """ 408 | Generate a sprite sheet from completed animation render 409 | (This operator just wraps the handler to make things easy if 410 | auto_sprite is False). 411 | """ 412 | bl_idname = "render.spritify" 413 | bl_label = "Generate a sprite sheet from a completed animation render" 414 | 415 | @classmethod 416 | def poll(cls, context): 417 | ''' 418 | Check if rendering is finished. 419 | See FIXME notes below regarding: 420 | - context.scene.render.filepath is //tmp which resolves to tmp 421 | in directory of blend file. However, being missing/empty 422 | isn't a reliable way of determining if the render finished. 423 | ''' 424 | tmp_path = bpy.path.abspath(context.scene.render.filepath) 425 | if context.scene is not None: 426 | if not os.path.isdir(tmp_path): 427 | return True # FIXME: See comment in next line. 428 | if (context.scene is not None) and len(os.listdir(tmp_path)) > 0: 429 | # FIXME: a bit hacky; an empty dir doesn't necessarily mean 430 | # that the render has been done 431 | return True 432 | else: 433 | return False 434 | print("[Spritify] not done yet") 435 | 436 | def execute(self, context): 437 | toggle = False 438 | if not context.scene.spritesheet.auto_sprite: 439 | context.scene.spritesheet.auto_sprite = True 440 | toggle = True 441 | self.show_results( 442 | spritify(self, context.scene) 443 | ) 444 | 445 | if toggle: 446 | context.scene.spritesheet.auto_sprite = False 447 | return {'FINISHED'} 448 | 449 | def show_results(self, results): 450 | ''' 451 | Handle output from helper functions (show error or message if any). 452 | See also show_results in GIFifyOperator. 453 | ''' 454 | if results is not None: 455 | error = results.get('error') 456 | message = results.get('message') 457 | if error is not None: 458 | self.report({'ERROR'}, error) 459 | elif message is not None: 460 | self.report({'INFO'}, message) 461 | 462 | 463 | class GIFifyOperator(bpy.types.Operator): 464 | """ 465 | Generate an animated GIF from completed animation render 466 | (This Operator just wraps the handler if auto_gif is False). 467 | """ 468 | bl_idname = "render.gifify" 469 | bl_label = "Generate an animated GIF from a completed animation render" 470 | 471 | @classmethod 472 | def poll(cls, context): 473 | ''' 474 | Check if rendering is finished. 475 | See FIXME notes below regarding: 476 | - context.scene.render.filepath is //tmp which resolves to tmp 477 | in directory of blend file. However, being missing/empty 478 | isn't a reliable way of determining if the render finished. 479 | ''' 480 | tmp_path = bpy.path.abspath(context.scene.render.filepath) 481 | if context.scene is not None: 482 | if not os.path.isdir(tmp_path): 483 | return True # FIXME: See comment in next line. 484 | if ((context.scene is not None) and (len(os.listdir(tmp_path)) > 0)): 485 | # FIXME: a bit hacky; an empty dir doesn't necessarily mean 486 | # that the render has been done 487 | return True 488 | else: 489 | return False 490 | print("[Spritify] not done yet") 491 | 492 | def execute(self, context): 493 | toggle = False 494 | if not context.scene.spritesheet.auto_gif: 495 | context.scene.spritesheet.auto_gif = True 496 | toggle = True 497 | self.show_results( 498 | gifify(context.scene, self) 499 | ) 500 | if results is not None: 501 | error = results.get('error') 502 | message = results.get('message') 503 | if error is not None: 504 | self.report({'ERROR'}, error) 505 | elif message is not None: 506 | self.report({'INFO'}, message) 507 | 508 | if toggle: 509 | context.scene.spritesheet.auto_gif = False 510 | return {'FINISHED'} 511 | 512 | def show_results(self, results): 513 | ''' 514 | Handle output from helper functions (show error or message if any). 515 | See also show_results in SpritifyOperator. 516 | ''' 517 | if results is not None: 518 | error = results.get('error') 519 | message = results.get('message') 520 | if error is not None: 521 | self.report({'ERROR'}, error) 522 | elif message is not None: 523 | self.report({'INFO'}, message) 524 | 525 | 526 | class SpritifyPanel(bpy.types.Panel): 527 | """UI Panel for Spritify""" 528 | bl_label = "Spritify" 529 | bl_idname = "RENDER_PT_spritify" 530 | bl_space_type = "PROPERTIES" 531 | bl_region_type = "WINDOW" 532 | bl_context = "render" 533 | 534 | def draw(self, context): 535 | layout = self.layout 536 | 537 | layout.prop(context.scene.spritesheet, "imagemagick_path") 538 | layout.prop(context.scene.spritesheet, "filepath") 539 | box = layout.box() 540 | split = box.split(factor=0.5) 541 | col = split.column() 542 | col.operator("render.spritify", text="Generate Sprite Sheet") 543 | col = split.column() 544 | col.prop(context.scene.spritesheet, "auto_sprite") 545 | split = box.split(factor=0.5) 546 | col = split.column(align=True) 547 | col.row().prop(context.scene.spritesheet, "is_rows", expand=True) 548 | col.prop(context.scene.spritesheet, "tiles") 549 | sub = col.split(factor=0.5) 550 | sub.prop(context.scene.spritesheet, "offset_x") 551 | sub.prop(context.scene.spritesheet, "offset_y") 552 | col = split.column() 553 | col.prop(context.scene.spritesheet, "bg_color") 554 | col.prop(context.scene.spritesheet, "quality", slider=True) 555 | box.prop(context.scene.spritesheet, "support_multiview") 556 | box = layout.box() 557 | split = box.split(factor=0.5) 558 | col = split.column() 559 | col.operator("render.gifify", text="Generate Animated GIF") 560 | col = split.column() 561 | col.prop(context.scene.spritesheet, "auto_gif") 562 | box.label(text="Animated GIF uses the spritesheet filepath") 563 | 564 | 565 | def register(): 566 | ''' 567 | Register the add-on. 568 | ''' 569 | bpy.utils.register_class(SpriteSheetProperties) 570 | bpy.types.Scene.spritesheet = bpy.props.PointerProperty( 571 | type=SpriteSheetProperties 572 | ) 573 | bpy.app.handlers.render_complete.append(spritify) 574 | bpy.app.handlers.render_complete.append(gifify) 575 | bpy.utils.register_class(SpritifyOperator) 576 | bpy.utils.register_class(GIFifyOperator) 577 | bpy.utils.register_class(SpritifyPanel) 578 | 579 | 580 | def unregister(): 581 | bpy.utils.unregister_class(SpritifyPanel) 582 | bpy.utils.unregister_class(SpritifyOperator) 583 | bpy.utils.unregister_class(GIFifyOperator) 584 | bpy.app.handlers.render_complete.remove(spritify) 585 | bpy.app.handlers.render_complete.remove(gifify) 586 | del bpy.types.Scene.spritesheet 587 | bpy.utils.unregister_class(SpriteSheetProperties) 588 | 589 | 590 | if __name__ == '__main__': 591 | register() 592 | --------------------------------------------------------------------------------