From 4579998663d163b795cc2ccdcd5d0c43ad443a1a Mon Sep 17 00:00:00 2001 From: Patrick-Emmanuel Boulanger-Nadeau Date: Mon, 20 Jan 2014 15:34:36 -0500 Subject: [PATCH 01/21] Prevent opening and parsing files which haven't been modified since the last time that the render was done --- overviewer_core/tileset.py | 2 +- overviewer_core/world.py | 42 +++++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/overviewer_core/tileset.py b/overviewer_core/tileset.py index 0f0e1c3..1660348 100644 --- a/overviewer_core/tileset.py +++ b/overviewer_core/tileset.py @@ -757,7 +757,7 @@ class TileSet(object): # Compare the last modified time of the chunk and tile. If the # tile is older, mark it in a RendertileSet object as dirty. - for chunkx, chunkz, chunkmtime in self.regionset.iterate_chunks(): + for chunkx, chunkz, chunkmtime in self.regionset.iterate_newer_chunks(last_rendertime): chunkcount += 1 diff --git a/overviewer_core/world.py b/overviewer_core/world.py index 9c2bc86..95effe5 100644 --- a/overviewer_core/world.py +++ b/overviewer_core/world.py @@ -272,7 +272,7 @@ class RegionSet(object): for x, y, regionfile in self._iterate_regionfiles(): # regionfile is a pathname - self.regionfiles[(x,y)] = regionfile + self.regionfiles[(x,y)] = (regionfile, os.path.getmtime(regionfile)) self.empty_chunk = [None,None] logging.debug("Done scanning regions") @@ -458,7 +458,7 @@ class RegionSet(object): """ - for (regionx, regiony), regionfile in self.regionfiles.iteritems(): + for (regionx, regiony), (regionfile, filemtime) in self.regionfiles.iteritems(): try: mcr = self._get_regionobj(regionfile) except nbt.CorruptRegionError: @@ -467,6 +467,27 @@ class RegionSet(object): for chunkx, chunky in mcr.get_chunks(): yield chunkx+32*regionx, chunky+32*regiony, mcr.get_chunk_timestamp(chunkx, chunky) + def iterate_newer_chunks(self, mtime): + """Returns an iterator over all chunk metadata in this world. Iterates + over tuples of integers (x,z,mtime) for each chunk. Other chunk data + is not returned here. + + """ + + for (regionx, regiony), (regionfile, filemtime) in self.regionfiles.iteritems(): + """ SKIP LOADING A REGION WHICH HAS NOT BEEN MODIFIED! """ + if (filemtime < mtime): + continue + + try: + mcr = self._get_regionobj(regionfile) + except nbt.CorruptRegionError: + logging.warning("Found a corrupt region file at %s,%s. Skipping it.", regionx, regiony) + continue + + for chunkx, chunky in mcr.get_chunks(): + yield chunkx+32*regionx, chunky+32*regiony, mcr.get_chunk_timestamp(chunkx, chunky) + def get_chunk_mtime(self, x, z): """Returns a chunk's mtime, or False if the chunk does not exist. This is therefore a dual purpose method. It corrects for the given north @@ -492,7 +513,7 @@ class RegionSet(object): Coords can be either be global chunk coords, or local to a region """ - regionfile = self.regionfiles.get((chunkX//32, chunkY//32),None) + (regionfile,filemtime) = self.regionfiles.get((chunkX//32, chunkY//32),None) return regionfile def _iterate_regionfiles(self): @@ -536,6 +557,8 @@ class RegionSetWrapper(object): return self._r.get_chunk(x,z) def iterate_chunks(self): return self._r.iterate_chunks() + def iterate_newer_chunks(self,filemtime): + return self._r.iterate_newer_chunks(filemtime) def get_chunk_mtime(self, x, z): return self._r.get_chunk_mtime(x,z) @@ -622,6 +645,11 @@ class RotatedRegionSet(RegionSetWrapper): x,z = self.rotate(x,z) yield x,z,mtime + def iterate_newer_chunks(self, filemtime): + for x,z,mtime in super(RotatedRegionSet, self).iterate_newer_chunks(filemtime): + x,z = self.rotate(x,z) + yield x,z,mtime + class CroppedRegionSet(RegionSetWrapper): def __init__(self, rsetobj, xmin, zmin, xmax, zmax): super(CroppedRegionSet, self).__init__(rsetobj) @@ -645,6 +673,14 @@ class CroppedRegionSet(RegionSetWrapper): self.xmin <= x <= self.xmax and self.zmin <= z <= self.zmax ) + + def iterate_newer_chunks(self, filemtime): + return ((x,z,mtime) for (x,z,mtime) in super(CroppedRegionSet,self).iterate_newer_chunks(filemtime) + if + self.xmin <= x <= self.xmax and + self.zmin <= z <= self.zmax + ) + def get_chunk_mtime(self,x,z): if ( self.xmin <= x <= self.xmax and From 8458451044321d8857bcfe9adeea514da3fafe1c Mon Sep 17 00:00:00 2001 From: Patrick-Emmanuel Boulanger-Nadeau Date: Mon, 20 Jan 2014 15:55:51 -0500 Subject: [PATCH 02/21] Respect the markall parameter --- overviewer_core/tileset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/overviewer_core/tileset.py b/overviewer_core/tileset.py index 1660348..6046ea4 100644 --- a/overviewer_core/tileset.py +++ b/overviewer_core/tileset.py @@ -757,8 +757,8 @@ class TileSet(object): # Compare the last modified time of the chunk and tile. If the # tile is older, mark it in a RendertileSet object as dirty. - for chunkx, chunkz, chunkmtime in self.regionset.iterate_newer_chunks(last_rendertime): + for chunkx, chunkz, chunkmtime in self.regionset.iterate_chunks() if markall else self.regionset.iterate_newer_chunks(last_rendertime): chunkcount += 1 if chunkmtime > max_chunk_mtime: From 2b2d929659bad4429353fd8ea2b608e3c3ffffef Mon Sep 17 00:00:00 2001 From: Patrick-Emmanuel Boulanger-Nadeau Date: Mon, 20 Jan 2014 16:04:11 -0500 Subject: [PATCH 03/21] Add the new function to the synthetic test --- test/test_tileset.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/test_tileset.py b/test/test_tileset.py index f555eac..864538f 100644 --- a/test/test_tileset.py +++ b/test/test_tileset.py @@ -1,3 +1,4 @@ +M import unittest import tempfile import shutil @@ -53,6 +54,10 @@ class FakeRegionset(object): for (x,z),mtime in self.chunks.iteritems(): yield x,z,mtime + def iterate_newer_chunks(self, filemtime): + for (x,z),mtime in self.chunks.iteritems(): + yield x,z,mtime + def get_chunk_mtime(self, x, z): try: return self.chunks[x,z] From a0640e8bdb0ef81173f83962dafe77f8fc39b81e Mon Sep 17 00:00:00 2001 From: Patrick-Emmanuel Boulanger-Nadeau Date: Mon, 20 Jan 2014 16:10:48 -0500 Subject: [PATCH 04/21] extra character ? --- test/test_tileset.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_tileset.py b/test/test_tileset.py index 864538f..5658d00 100644 --- a/test/test_tileset.py +++ b/test/test_tileset.py @@ -1,4 +1,3 @@ -M import unittest import tempfile import shutil From c438a37b295a14dbaa08ddfc01a05eb5ec8c97cd Mon Sep 17 00:00:00 2001 From: Patrick-Emmanuel Boulanger-Nadeau Date: Mon, 20 Jan 2014 16:30:08 -0500 Subject: [PATCH 05/21] Default value is compatible with the expecteed result in fetching the region for a path --- overviewer_core/world.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/overviewer_core/world.py b/overviewer_core/world.py index 95effe5..f20e317 100644 --- a/overviewer_core/world.py +++ b/overviewer_core/world.py @@ -513,7 +513,7 @@ class RegionSet(object): Coords can be either be global chunk coords, or local to a region """ - (regionfile,filemtime) = self.regionfiles.get((chunkX//32, chunkY//32),None) + (regionfile,filemtime) = self.regionfiles.get((chunkX//32, chunkY//32),(None, None)) return regionfile def _iterate_regionfiles(self): From 6ee3eba550c4be1fc52a04095b2508a171d2449d Mon Sep 17 00:00:00 2001 From: Patrick-Emmanuel Boulanger-Nadeau Date: Tue, 21 Jan 2014 10:08:19 -0500 Subject: [PATCH 06/21] Do not use the newer than check on windows since apparently minecraft will not force a timestamp update on the file when they are written until it's closed. --- overviewer_core/tileset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/overviewer_core/tileset.py b/overviewer_core/tileset.py index 6046ea4..d14bde2 100644 --- a/overviewer_core/tileset.py +++ b/overviewer_core/tileset.py @@ -758,7 +758,7 @@ class TileSet(object): # tile is older, mark it in a RendertileSet object as dirty. - for chunkx, chunkz, chunkmtime in self.regionset.iterate_chunks() if markall else self.regionset.iterate_newer_chunks(last_rendertime): + for chunkx, chunkz, chunkmtime in self.regionset.iterate_chunks() if (markall || platform.system() == 'Windows') else self.regionset.iterate_newer_chunks(last_rendertime): chunkcount += 1 if chunkmtime > max_chunk_mtime: From 8023b52fdcc20a951349e151a473ff88d991e83e Mon Sep 17 00:00:00 2001 From: Patrick-Emmanuel Boulanger-Nadeau Date: Tue, 21 Jan 2014 10:52:53 -0500 Subject: [PATCH 07/21] Pythonic or --- overviewer_core/tileset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/overviewer_core/tileset.py b/overviewer_core/tileset.py index d14bde2..41a8756 100644 --- a/overviewer_core/tileset.py +++ b/overviewer_core/tileset.py @@ -758,7 +758,7 @@ class TileSet(object): # tile is older, mark it in a RendertileSet object as dirty. - for chunkx, chunkz, chunkmtime in self.regionset.iterate_chunks() if (markall || platform.system() == 'Windows') else self.regionset.iterate_newer_chunks(last_rendertime): + for chunkx, chunkz, chunkmtime in self.regionset.iterate_chunks() if (markall or platform.system() == 'Windows') else self.regionset.iterate_newer_chunks(last_rendertime): chunkcount += 1 if chunkmtime > max_chunk_mtime: From 18beae66249833164a151c71bf905cd3eaec0bc4 Mon Sep 17 00:00:00 2001 From: Patrick-Emmanuel Boulanger-Nadeau Date: Tue, 21 Jan 2014 10:55:13 -0500 Subject: [PATCH 08/21] Import the platform --- overviewer_core/tileset.py | 1 + 1 file changed, 1 insertion(+) diff --git a/overviewer_core/tileset.py b/overviewer_core/tileset.py index 41a8756..8899993 100644 --- a/overviewer_core/tileset.py +++ b/overviewer_core/tileset.py @@ -24,6 +24,7 @@ import functools import time import errno import stat +import platform from collections import namedtuple from itertools import product, izip, chain From 09477ed8a0a8b06764404c60add0c8b2744ce1ad Mon Sep 17 00:00:00 2001 From: CounterPillow Date: Tue, 4 Mar 2014 00:39:59 +0100 Subject: [PATCH 09/21] Rewrote image optimisation stuff. The old one was broken anyway. --- docs/config.rst | 88 +++++++++++++++++++-- overviewer_core/optimizeimages.py | 110 ++++++++++++++++++++------ overviewer_core/settingsDefinition.py | 3 +- overviewer_core/settingsValidators.py | 10 ++- overviewer_core/tileset.py | 12 +-- 5 files changed, 182 insertions(+), 41 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 5d63d21..d1bc878 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -545,18 +545,92 @@ values. The valid configuration keys are listed below. **Default:** ``95`` ``optimizeimg`` + + .. warning:: + Using image optimizers will increase render times significantly. + This option specifies which additional tools overviewer should use to optimize the filesize of png tiles. The tools used must be placed somewhere, where overviewer can find them, for example the "PATH" environment variable or a directory like /usr/bin. - This should be an integer between 0 and 3. - * ``1 - Use pngcrush`` - * ``2 - Use advdef`` - * ``3 - Use pngcrush and advdef (Not recommended)`` - Using this option may significantly increase render time, but will make - the resulting tiles smaller, with lossless image quality. - **Default:** ``0`` + The option is a list of Optimizer objects, which are then executed in + the order in which they're specified:: + + from optimizeimages import pngnq, optipng + worlds["world"] = "/path/to/world" + + renders["daytime"] = { + "world":"world", + "title":"day", + "rendermode":smooth_lighting, + "optimizeimg":[pngnq(sampling=1), optipng(olevel=3)], + } + + Here is a list of supported image optimization programs: + + ``pngnq`` + pngnq quantizes 32-bit RGBA images into 8-bit RGBA palette PNGs. This is + lossy, but reduces filesize significantly. Available settings: + + ``sampling`` + An integer between ``1`` and ``10``, ``1`` samples all pixels, is slow and yields + the best quality. Higher values sample less of the image, which makes + the process faster, but less accurate. + + **Default:** ``3`` + + ``dither`` + Either the string ``"n"`` for no dithering, or ``"f"`` for Floyd + Steinberg dithering. Dithering helps eliminate colorbanding, sometimes + increasing visual quality. + + .. warning:: + With pngnq version 1.0 (which is what Ubuntu 12.04 ships), the + dithering option is broken. Only the default, no dithering, + can be specified on those systems. + + **Default:** ``"n"`` + + .. warning:: + Because of several PIL bugs, only the most zoomed in level has transparency + when using pngnq. The other zoom levels have all transparency replaced by + black. This is *not* pngnq's fault, as pngnq supports multiple levels of + transparency just fine, it's PIL's fault for not even reading indexed + PNGs correctly. + + ``optipng`` + optipng tunes the deflate algorithm and removes unneeded channels from the PNG, + producing a smaller, lossless output image. It was inspired by pngcrush. + Available settings: + + ``olevel`` + An integer between ``0`` (few optimizations) and ``7`` (many optimizations). + The default should be satisfactory for everyone, higher levels than the default + see almost no benefit. + + **Default:** ``2`` + + ``pngcrush`` + pngcrush is very slow and not very good, you should use optipng in probably all cases. + However, Overviewer still allows you to use it because we're nice people like that. + Available settings: + + ``brute`` + Either ``True`` or ``False``. Cycles through all compression methods, and is very slow. + + .. note:: + There is practically no reason to ever use this. optipng will beat pngcrush, and + throwing more CPU time at pngcrush most likely won't help. If you think you need + this option, then you are most likely wrong. + + **Default:** ``False`` + + .. note:: + Don't forget to import the optimizers you use in your settings file, as shown in the + example above. + + **Default:** ``[]`` ``bgcolor`` This is the background color to be displayed behind the map. Its value diff --git a/overviewer_core/optimizeimages.py b/overviewer_core/optimizeimages.py index 4422feb..f31b6a1 100644 --- a/overviewer_core/optimizeimages.py +++ b/overviewer_core/optimizeimages.py @@ -17,36 +17,96 @@ import os import subprocess import shlex -pngcrush = "pngcrush" -optipng = "optipng" -advdef = "advdef" +class Optimizer: + binaryname = "" -def check_programs(level): + def __init__(self): + raise NotImplementedError("I can't let you do that, Dave.") + + def optimize(self, img): + raise NotImplementedError("I can't let you do that, Dave.") + + def fire_and_forget(self, args): + subprocess.Popen(args, stderr=subprocess.STDOUT, stdout=subprocess.PIPE).communicate()[0] + +class NonAtomicOptimizer(Optimizer): + def cleanup(self, img): + os.rename(img + ".tmp", img) + + def fire_and_forget(self, args, img): + subprocess.Popen(args, stderr=subprocess.STDOUT, stdout=subprocess.PIPE).communicate()[0] + self.cleanup(img) + +class PNGOptimizer: + def __init__(self): + raise NotImplementedError("I can't let you do that, Dave.") + +class JPEGOptimizer: + def __init__(self): + raise NotImplementedError("I can't let you do that, Dave.") + +class pngnq(NonAtomicOptimizer, PNGOptimizer): + binaryname = "pngnq" + + def __init__(self, sampling=3, dither="n"): + if sampling < 1 or sampling > 10: + raise Exception("Invalid sampling value '%d' for pngnq!" % sampling) + + if dither not in ["n", "f"]: + raise Exception("Invalid dither method '%s' for pngnq!" % dither) + + self.sampling = sampling + self.dither = dither + + def optimize(self, img): + if img.endswith(".tmp"): + extension = ".tmp" + else: + extension = ".png.tmp" + + NonAtomicOptimizer.fire_and_forget(self, [self.binaryname, "-s", str(self.sampling), + "-Q", self.dither, "-e", extension, img], img) + +class pngcrush(NonAtomicOptimizer, PNGOptimizer): + binaryname = "pngcrush" + # really can't be bothered to add some interface for all + # the pngcrush options, it sucks anyway + def __init__(self, brute=False): + self.brute = brute + + def optimize(self, img): + args = [self.binaryname, img, img + ".tmp"] + if self.brute == True: # Was the user an idiot? + args.insert(1, "-brute") + + NonAtomicOptimizer.fire_and_forget(self, args, img) + +class optipng(Optimizer, PNGOptimizer): + binaryname = "optipng" + + def __init__(self, olevel=2): + self.olevel = olevel + + def optimize(self, img): + Optimizer.fire_and_forget(self, [self.binaryname, "-o" + str(self.olevel), "-quiet", img]) + + +def check_programs(optimizers): path = os.environ.get("PATH").split(os.pathsep) def exists_in_path(prog): result = filter(lambda x: os.path.exists(os.path.join(x, prog)), path) return len(result) != 0 - for prog,l in [(pngcrush,1), (advdef,2)]: - if l <= level: - if (not exists_in_path(prog)) and (not exists_in_path(prog + ".exe")): - raise Exception("Optimization prog %s for level %d not found!" % (prog, l)) - -def optimize_image(imgpath, imgformat, optimizeimg): - if imgformat == 'png': - if optimizeimg >= 1: - # we can't do an atomic replace here because windows is terrible - # so instead, we make temp files, delete the old ones, and rename - # the temp files. go windows! - subprocess.Popen([pngcrush, imgpath, imgpath + ".tmp"], - stderr=subprocess.STDOUT, stdout=subprocess.PIPE).communicate()[0] - os.remove(imgpath) - os.rename(imgpath+".tmp", imgpath) - - if optimizeimg >= 2: - # the "-nc" it's needed to no broke the transparency of tiles - recompress_option = "-z2" if optimizeimg == 2 else "-z4" - subprocess.Popen([advdef, recompress_option,imgpath], stderr=subprocess.STDOUT, - stdout=subprocess.PIPE).communicate()[0] + for opt in optimizers: + if (not exists_in_path(opt.binaryname)) and (not exists_in_path(opt.binaryname + ".exe")): + raise Exception("Optimization program '%s' was not found!" % opt.binaryname) +def optimize_image(imgpath, imgformat, optimizers): + for opt in optimizers: + if imgformat == 'png': + if isinstance(opt, PNGOptimizer): + opt.optimize(imgpath) + elif imgformat == 'jpg': + if isinstance(opt, JPEGOptimizer): + opt.optimize(imgpath) diff --git a/overviewer_core/settingsDefinition.py b/overviewer_core/settingsDefinition.py index 41b8970..c17a171 100644 --- a/overviewer_core/settingsDefinition.py +++ b/overviewer_core/settingsDefinition.py @@ -46,6 +46,7 @@ from settingsValidators import * import util from observer import ProgressBarObserver, LoggingObserver, JSObserver +from optimizeimages import pngnq, optipng, pngcrush import platform import sys @@ -72,7 +73,7 @@ renders = Setting(required=True, default=util.OrderedDict(), "imgquality": Setting(required=False, validator=validateImgQuality, default=95), "bgcolor": Setting(required=True, validator=validateBGColor, default="1a1a1a"), "defaultzoom": Setting(required=True, validator=validateDefaultZoom, default=1), - "optimizeimg": Setting(required=True, validator=validateOptImg, default=0), + "optimizeimg": Setting(required=True, validator=validateOptImg, default=[]), "nomarkers": Setting(required=False, validator=validateBool, default=None), "texturepath": Setting(required=False, validator=validateTexturePath, default=None), "renderchecks": Setting(required=False, validator=validateInt, default=None), diff --git a/overviewer_core/settingsValidators.py b/overviewer_core/settingsValidators.py index 6b5afa9..0279ed7 100644 --- a/overviewer_core/settingsValidators.py +++ b/overviewer_core/settingsValidators.py @@ -5,6 +5,7 @@ from collections import namedtuple import rendermodes import util +from optimizeimages import Optimizer from world import UPPER_LEFT, UPPER_RIGHT, LOWER_LEFT, LOWER_RIGHT class ValidationException(Exception): @@ -155,8 +156,13 @@ def validateBGColor(color): return color -def validateOptImg(opt): - return bool(opt) +def validateOptImg(optimizers): + if isinstance(optimizers, (int, long)): + raise ValidationException("You are using a deprecated method of specifying optimizeimg!") + for opt in optimizers: + if not isinstance(opt, Optimizer): + raise ValidationException("Invalid Optimizer!") + return optimizers def validateTexturePath(path): # Expand user dir in directories strings diff --git a/overviewer_core/tileset.py b/overviewer_core/tileset.py index 3e13129..a2338a8 100644 --- a/overviewer_core/tileset.py +++ b/overviewer_core/tileset.py @@ -246,11 +246,7 @@ class TileSet(object): relevant in jpeg mode. optimizeimg - an integer indiating optimizations to perform on png outputs. 0 - indicates no optimizations. Only relevant in png mode. - 1 indicates pngcrush is run on all output images - 2 indicates pngcrush and advdef are run on all output images with advdef -z2 - 3 indicates pngcrush and advdef are run on all output images with advdef -z4 + A list of optimizer instances to use. rendermode Perhaps the most important/relevant option: a string indicating the @@ -892,7 +888,11 @@ class TileSet(object): try: #quad = Image.open(path[1]).resize((192,192), Image.ANTIALIAS) src = Image.open(path[1]) + # optimizeimg may have converted them to a palette image in the meantime + if src.mode != "RGB" and src.mode != "RGBA": + src = src.convert("RGBA") src.load() + quad = Image.new("RGBA", (192, 192), self.options['bgcolor']) resize_half(quad, src) img.paste(quad, path[0]) @@ -1017,7 +1017,7 @@ class TileSet(object): if self.options['optimizeimg']: optimize_image(tmppath, self.imgextension, self.options['optimizeimg']) - + os.utime(tmppath, (max_chunk_mtime, max_chunk_mtime)) def _iterate_and_check_tiles(self, path): From 1cf131a8fc410be95e3560c7f90fec970bdecde6 Mon Sep 17 00:00:00 2001 From: Luc Ritchie Date: Sat, 8 Mar 2014 23:01:40 -0500 Subject: [PATCH 10/21] Handle UUID player files semi-nicely in POIgen (14w10a+, 1.7.6+) --- overviewer_core/aux_files/genPOI.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/overviewer_core/aux_files/genPOI.py b/overviewer_core/aux_files/genPOI.py index 5fa4b00..367eb19 100755 --- a/overviewer_core/aux_files/genPOI.py +++ b/overviewer_core/aux_files/genPOI.py @@ -132,7 +132,11 @@ def handlePlayers(rset, render, worldpath): dimension = int(mystdim.group(1)) else: raise - playerdir = os.path.join(worldpath, "players") + # TODO: get player names from UUIDs once Mojang makes available an API to do it + playerdir = os.path.join(worldpath, "playerdata") + if not os.path.isdir(playerdir): + playerdir = os.path.join(worldpath, "players") + if os.path.isdir(playerdir): playerfiles = os.listdir(playerdir) playerfiles = [x for x in playerfiles if x.endswith(".dat")] From 0e1bd4369a0aaf0e1734fcc5974705dc9f0428be Mon Sep 17 00:00:00 2001 From: Luc Ritchie Date: Sun, 9 Mar 2014 16:22:00 -0400 Subject: [PATCH 11/21] Use Mojang's session API to get usernames from UUIDs --- overviewer_core/aux_files/genPOI.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/overviewer_core/aux_files/genPOI.py b/overviewer_core/aux_files/genPOI.py index 367eb19..72eae2f 100755 --- a/overviewer_core/aux_files/genPOI.py +++ b/overviewer_core/aux_files/genPOI.py @@ -19,6 +19,7 @@ import logging import json import sys import re +import urllib2 import Queue import multiprocessing @@ -30,6 +31,8 @@ from overviewer_core import logger from overviewer_core import nbt from overviewer_core import configParser, world +UUID_LOOKUP_URL = 'https://sessionserver.mojang.com/session/minecraft/profile/' + def replaceBads(s): "Replaces bad characters with good characters!" bads = [" ", "(", ")"] @@ -132,10 +135,11 @@ def handlePlayers(rset, render, worldpath): dimension = int(mystdim.group(1)) else: raise - # TODO: get player names from UUIDs once Mojang makes available an API to do it playerdir = os.path.join(worldpath, "playerdata") + useUUIDs = True if not os.path.isdir(playerdir): playerdir = os.path.join(worldpath, "players") + useUUIDs = False if os.path.isdir(playerdir): playerfiles = os.listdir(playerdir) @@ -156,6 +160,13 @@ def handlePlayers(rset, render, worldpath): logging.warning("Skipping bad player dat file %r", playerfile) continue playername = playerfile.split(".")[0] + if useUUIDs: + try: + profile = json.loads(urllib2.urlopen(UUID_LOOKUP_URL + playername.replace('-','')).read()) + if 'name' in profile: + playername = profile['name'] + except ValueError: + logging.warning("Unable to get player name for UUID %s", playername) if isSinglePlayer: playername = 'Player' if data['Dimension'] == dimension: From 3f222796c573fd21e68a449df2e7c3577ae95b32 Mon Sep 17 00:00:00 2001 From: Andrew Chin Date: Sun, 30 Mar 2014 18:50:46 -0400 Subject: [PATCH 12/21] Print a limit=1 traceback if failed to build --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index cc55dce..1b4b30d 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import sys +import traceback # quick version check if not (sys.version_info[0] == 2 and sys.version_info[1] >= 6): @@ -272,6 +273,7 @@ class CustomBuild(build): build.run(self) print("\nBuild Complete") except Exception: + traceback.print_exc(limit=1) print("\nFailed to build Overviewer!") print("Please review the errors printed above and the build instructions") print("at . If you are") From a8fc3300b4e1432ec424cf279692851cf0be5a50 Mon Sep 17 00:00:00 2001 From: Aaron Griffith Date: Sat, 5 Apr 2014 20:29:58 -0400 Subject: [PATCH 13/21] general fixes for min/maxzoom, documentation cleared up hopeful fix for #1086 --- docs/config.rst | 20 +++++++++++++++----- overviewer_core/data/js_src/util.js | 21 +++++++++++++++++---- overviewer_core/tileset.py | 2 ++ 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 5d63d21..24067fe 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -566,13 +566,22 @@ values. The valid configuration keys are listed below. **Default:** ``#1a1a1a`` ``defaultzoom`` - This value specifies the default zoom level that the map will be opened - with. It has to be greater than 0. + This value specifies the default zoom level that the map will be + opened with. It has to be greater than 0, which corresponds to the + most zoomed-out level. If you use ``minzoom`` or ``maxzoom``, it + should be between those two. **Default:** ``1`` ``maxzoom`` - This specifies the maximum zoom allowed by the zoom control on the web page. + This specifies the maximum, closest in zoom allowed by the zoom + control on the web page. This is relative to 0, the farthest-out + image, so setting this to 8 will allow you to zoom in at most 8 + times. This is *not* relative to ``minzoom``, so setting + ``minzoom`` will shave off even more levels. If you wish to + specify how many zoom levels to leave off, instead of how many + total to use, use a negative number here. For example, setting + this to -2 will disable the two most zoomed-in levels. .. note:: @@ -583,8 +592,9 @@ values. The valid configuration keys are listed below. **Default:** Automatically set to most detailed zoom level ``minzoom`` - This specifies the minimum zoom allowed by the zoom control on the web page. For - example, setting this to 2 will disable the two most-zoomed out levels. + This specifies the minimum, farthest away zoom allowed by the zoom + control on the web page. For example, setting this to 2 will + disable the two most zoomed-out levels. .. note:: diff --git a/overviewer_core/data/js_src/util.js b/overviewer_core/data/js_src/util.js index 62bad82..6121cf5 100644 --- a/overviewer_core/data/js_src/util.js +++ b/overviewer_core/data/js_src/util.js @@ -119,7 +119,7 @@ overviewer.util = { zoom = overviewer.mapView.options.currentTileSet.get('minZoom'); } else { zoom = parseInt(zoom); - if (zoom < 0 && zoom + overviewer.mapView.options.currentTileSet.get('maxZoom') >= 0) { + if (zoom < 0) { // if zoom is negative, treat it as a "zoom out from max" zoom += overviewer.mapView.options.currentTileSet.get('maxZoom'); } else { @@ -127,6 +127,13 @@ overviewer.util = { zoom = overviewer.mapView.options.currentTileSet.get('defaultZoom'); } } + + // clip zoom + if (zoom > overviewer.mapView.options.currentTileSet.get('maxZoom')) + zoom = overviewer.mapView.options.currentTileSet.get('maxZoom'); + if (zoom < overviewer.mapView.options.currentTileSet.get('minZoom')) + zoom = overviewer.mapView.options.currentTileSet.get('minZoom'); + overviewer.map.setZoom(zoom); } @@ -512,9 +519,9 @@ overviewer.util = { } - if (zoom == currTileset.get('maxZoom')) { + if (zoom >= currTileset.get('maxZoom')) { zoom = 'max'; - } else if (zoom == currTileset.get('minZoom')) { + } else if (zoom <= currTileset.get('minZoom')) { zoom = 'min'; } else { // default to (map-update friendly) negative zooms @@ -556,7 +563,7 @@ overviewer.util = { zoom = tsetModel.get('minZoom'); } else { zoom = parseInt(zoom); - if (zoom < 0 && zoom + tsetModel.get('maxZoom') >= 0) { + if (zoom < 0) { // if zoom is negative, treat it as a "zoom out from max" zoom += tsetModel.get('maxZoom'); } else { @@ -565,6 +572,12 @@ overviewer.util = { } } + // clip zoom + if (zoom > tsetModel.get('maxZoom')) + zoom = tsetModel.get('maxZoom'); + if (zoom < tsetModel.get('minZoom')) + zoom = tsetModel.get('minZoom'); + overviewer.map.setCenter(latlngcoords); overviewer.map.setZoom(zoom); var locationmarker = new overviewer.views.LocationIconView(); diff --git a/overviewer_core/tileset.py b/overviewer_core/tileset.py index e665bff..032dcdb 100644 --- a/overviewer_core/tileset.py +++ b/overviewer_core/tileset.py @@ -552,7 +552,9 @@ class TileSet(object): poititle = self.options.get("poititle"), showlocationmarker = self.options.get("showlocationmarker") ) + d['maxZoom'] = min(self.treedepth, d['maxZoom']) d['minZoom'] = min(max(0, self.options.get("minzoom", 0)), d['maxZoom']) + d['defaultZoom'] = max(d['minZoom'], min(d['defaultZoom'], d['maxZoom'])) if isOverlay: d.update({"tilesets": self.options.get("overlay")}) From 8e5944d5c3fc1c1aae8331daa1b72af0c29d8702 Mon Sep 17 00:00:00 2001 From: Andrew Chin Date: Thu, 10 Apr 2014 19:52:50 -0400 Subject: [PATCH 14/21] Catch some additional UUID lookup errors from urllib2 --- overviewer_core/aux_files/genPOI.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/overviewer_core/aux_files/genPOI.py b/overviewer_core/aux_files/genPOI.py index 72eae2f..f0f5492 100755 --- a/overviewer_core/aux_files/genPOI.py +++ b/overviewer_core/aux_files/genPOI.py @@ -165,7 +165,7 @@ def handlePlayers(rset, render, worldpath): profile = json.loads(urllib2.urlopen(UUID_LOOKUP_URL + playername.replace('-','')).read()) if 'name' in profile: playername = profile['name'] - except ValueError: + except (ValueError, urllib2.URLError): logging.warning("Unable to get player name for UUID %s", playername) if isSinglePlayer: playername = 'Player' From 9a3305932e5e1e72e8f78f29cb70e9ffa0c45343 Mon Sep 17 00:00:00 2001 From: rymate1234 Date: Fri, 11 Apr 2014 16:10:56 +0200 Subject: [PATCH 15/21] Add a small message stating where the render is and how to open it --- overviewer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/overviewer.py b/overviewer.py index f5b8969..a9a82ae 100755 --- a/overviewer.py +++ b/overviewer.py @@ -514,6 +514,8 @@ dir but you forgot to put quotes around the directory, since it contains spaces. if options.pid: os.remove(options.pid) + logging.info("Your render has been written to '%s', open index.html to view it" % destdir) + return 0 def list_worlds(): From 7d87d2565828da7e75ee497e2299f5a262a01bdc Mon Sep 17 00:00:00 2001 From: Andrew Chin Date: Tue, 15 Apr 2014 11:22:41 -0400 Subject: [PATCH 16/21] Fix ServerAnnounceObserver typo --- overviewer_core/observer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/overviewer_core/observer.py b/overviewer_core/observer.py index e9a5370..32a472c 100644 --- a/overviewer_core/observer.py +++ b/overviewer_core/observer.py @@ -345,7 +345,7 @@ class ServerAnnounceObserver(Observer): def update(self, current_value): super(ServerAnnounceObserver, self).update(current_value) - if self._need_update(current_value): + if self._need_update(): self._send_output('Rendered %d of %d tiles, %d%% complete' % (self.get_current_value(), self.get_max_value(), self.get_percentage())) From 7c23d6e86ac85f7e1bec66aab71a541347a979b4 Mon Sep 17 00:00:00 2001 From: Aaron Griffith Date: Thu, 1 May 2014 16:48:28 -0400 Subject: [PATCH 17/21] added rendercheck mode 3, the identity function of rendercheck modes! --- docs/config.rst | 8 ++++++++ overviewer.py | 17 +++++++++++------ overviewer_core/tileset.py | 36 +++++++++++++++++++++++++++++++++--- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 24067fe..2b6cfe2 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -704,6 +704,14 @@ values. The valid configuration keys are listed below. 'forcerender': True, } +``renderchecks`` + This is an integer, and functions as a more complex form of + ``forcerender``. Setting it to 1 enables :option:`--check-tiles` + mode, setting it to 2 enables :option:`--forcerender`, and 3 tells + Overviewer to keep this particular render in the output, but + otherwise don't update it. It defaults to 0, which is the usual + update checking mode. + ``changelist`` This is a string. It names a file where it will write out, one per line, the path to tiles that have been updated. You can specify the same file for diff --git a/overviewer.py b/overviewer.py index f5b8969..71e1c09 100755 --- a/overviewer.py +++ b/overviewer.py @@ -318,19 +318,24 @@ dir but you forgot to put quotes around the directory, since it contains spaces. "--check-tiles, and --no-tile-checks. These options conflict.") parser.print_help() return 1 + + def set_renderchecks(checkname, num): + for name, render in config['renders'].iteritems(): + if render.get('renderchecks', 0) == 3: + logging.warning(checkname + " ignoring render " + repr(name) + " since it's marked as \"don't render\".") + else: + render['renderchecks'] = num + if options.forcerender: logging.info("Forcerender mode activated. ALL tiles will be rendered") - for render in config['renders'].itervalues(): - render['renderchecks'] = 2 + set_renderchecks("forcerender", 2) elif options.checktiles: logging.info("Checking all tiles for updates manually.") - for render in config['renders'].itervalues(): - render['renderchecks'] = 1 + set_renderchecks("checktiles", 1) elif options.notilechecks: logging.info("Disabling all tile mtime checks. Only rendering tiles "+ "that need updating since last render") - for render in config['renders'].itervalues(): - render['renderchecks'] = 0 + set_renderchecks("notilechecks", 0) if not config['renders']: logging.error("You must specify at least one render in your config file. See the docs if you're having trouble") diff --git a/overviewer_core/tileset.py b/overviewer_core/tileset.py index 032dcdb..8d3e8d6 100644 --- a/overviewer_core/tileset.py +++ b/overviewer_core/tileset.py @@ -130,6 +130,14 @@ Bounds = namedtuple("Bounds", ("mincol", "maxcol", "minrow", "maxrow")) # slowest, but SHOULD be specified if this is the first render because # the scan will forgo tile stat calls. It's also useful for changing # texture packs or other options that effect the output. + +# 3 +# A very special mode. Using this will not actually render +# anything, but will leave this tileset in the resulting +# map. Useful for renders that you want to keep, but not +# update. Since this mode is so simple, it's left out of the +# rest of this discussion. + # # For 0 our caller has explicitly requested not to check mtimes on disk to # speed things up. So the mode 0 chunk scan only looks at chunk mtimes and the @@ -238,6 +246,13 @@ class TileSet(object): useful for changing texture packs or other options that effect the output. + 3 + A very special mode. Using this will not actually render + anything, but will leave this tileset in the resulting + map. Useful for renders that you want to keep, but not + update. Since this mode is so simple, it's left out of the + rest of this discussion. + imgformat A string indicating the output format. Must be one of 'png' or 'jpeg' @@ -390,6 +405,11 @@ class TileSet(object): attribute for later use in iterate_work_items() """ + + # skip if we're told to + if self.options['renderchecks'] == 3: + return + # REMEMBER THAT ATTRIBUTES ASSIGNED IN THIS METHOD ARE NOT AVAILABLE IN # THE do_work() METHOD (because this is only called in the main process # not the workers) @@ -416,15 +436,16 @@ class TileSet(object): return 1 def get_phase_length(self, phase): - """Returns the number of work items in a given phase, or None if there - is no good estimate. + """Returns the number of work items in a given phase. """ # Yeah functional programming! + # and by functional we mean a bastardized python switch statement return { 0: lambda: self.dirtytree.count_all(), #there is no good way to guess this so just give total count 1: lambda: (4**(self.treedepth+1)-1)/3, 2: lambda: self.dirtytree.count_all(), + 3: lambda: 0, }[self.options['renderchecks']]() def iterate_work_items(self, phase): @@ -434,6 +455,10 @@ class TileSet(object): This method returns an iterator over (obj, [dependencies, ...]) """ + # skip if asked to + if self.options['renderchecks'] == 3: + return + # The following block of code implementes the changelist functionality. fd = self.options.get("changelist", None) if fd: @@ -536,6 +561,11 @@ class TileSet(object): def bgcolorformat(color): return "#%02x%02x%02x" % color[0:3] isOverlay = self.options.get("overlay") or (not any(isinstance(x, rendermodes.Base) for x in self.options.get("rendermode"))) + + # don't update last render time if we're leaving this alone + last_rendertime = self.last_rendertime + if self.options['renderchecks'] != 3: + last_rendertime = self.max_chunk_mtime d = dict(name = self.options.get('title'), zoomLevels = self.treedepth, @@ -546,7 +576,7 @@ class TileSet(object): bgcolor = bgcolorformat(self.options.get('bgcolor')), world = self.options.get('worldname_orig') + (" - " + self.options.get('dimension')[0] if self.options.get('dimension')[1] != 0 else ''), - last_rendertime = self.max_chunk_mtime, + last_rendertime = last_rendertime, imgextension = self.imgextension, isOverlay = isOverlay, poititle = self.options.get("poititle"), From 9487d6f5bdb45d50d8e0388126f35d310e887a0c Mon Sep 17 00:00:00 2001 From: Andrew Chin Date: Sat, 3 May 2014 00:23:10 -0400 Subject: [PATCH 18/21] Added a --check-version option --- overviewer.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/overviewer.py b/overviewer.py index 71e1c09..4d22e3a 100755 --- a/overviewer.py +++ b/overviewer.py @@ -84,6 +84,8 @@ def main(): help="Tries to locate the texture files. Useful for debugging texture problems.") parser.add_option("-V", "--version", dest="version", help="Displays version information and then exits", action="store_true") + parser.add_option("--check-version", dest="checkversion", + help="Fetchs information about the latest version of Overviewer", action="store_true") parser.add_option("--update-web-assets", dest='update_web_assets', action="store_true", help="Update web assets. Will *not* render tiles or update overviewerConfig.js") @@ -141,8 +143,28 @@ def main(): if options.verbose > 0: print("Python executable: %r" % sys.executable) print(sys.version) + if not options.checkversion: + return 0 + if options.checkversion: + print("Currently running Minecraft Overviewer %s" % util.findGitVersion()), + print("(%s)" % util.findGitHash()[:7]) + try: + import urllib + import json + latest_ver = json.loads(urllib.urlopen("http://overviewer.org/download.json").read())['src'] + print("Latest version of Minecraft Overviewer %s (%s)" % (latest_ver['version'], latest_ver['commit'][:7])) + print("See http://overviewer.org/downloads for more information") + except Exception: + print("Failed to fetch latest version info.") + if options.verbose > 0: + import traceback + traceback.print_exc() + else: + print("Re-run with --verbose for more details") + return 1 return 0 + if options.pid: if os.path.exists(options.pid): try: From 866c2fe064ed7045a1536b479b5739950ae3e44e Mon Sep 17 00:00:00 2001 From: CounterPillow Date: Tue, 6 May 2014 19:54:43 +0200 Subject: [PATCH 19/21] Work around and warn if old optimizeimg definition Before someone says this is incorrect because it only ever uses pngcrush: The old code always used pngcrush and nothing else anyway. This is absolutely correct and the old behaviour. I also added a check to make sure it's a list, as some people might forget the whole list thing. --- overviewer_core/settingsValidators.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/overviewer_core/settingsValidators.py b/overviewer_core/settingsValidators.py index 0279ed7..6b53bee 100644 --- a/overviewer_core/settingsValidators.py +++ b/overviewer_core/settingsValidators.py @@ -158,7 +158,12 @@ def validateBGColor(color): def validateOptImg(optimizers): if isinstance(optimizers, (int, long)): - raise ValidationException("You are using a deprecated method of specifying optimizeimg!") + from optimizeimages import pngcrush + import logging + logging.warning("You're using a deprecated definition of optimizeimg. We'll do what you say for now, but please fix this as soon as possible.") + optimizers = [pngcrush()] + if not isinstance(optimizers, list): + raise ValidationException("optimizeimg is not a list. Make sure you specify them like [foo()], with square brackets.") for opt in optimizers: if not isinstance(opt, Optimizer): raise ValidationException("Invalid Optimizer!") From 8817972b36145f5531f25bb1da40b9cd064ecbc4 Mon Sep 17 00:00:00 2001 From: CounterPillow Date: Tue, 6 May 2014 20:10:42 +0200 Subject: [PATCH 20/21] Move the check for program availability into class We also actually execute it now. Go us! --- overviewer_core/optimizeimages.py | 23 ++++++++++++----------- overviewer_core/settingsValidators.py | 5 ++++- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/overviewer_core/optimizeimages.py b/overviewer_core/optimizeimages.py index f31b6a1..c877e75 100644 --- a/overviewer_core/optimizeimages.py +++ b/overviewer_core/optimizeimages.py @@ -16,6 +16,7 @@ import os import subprocess import shlex +import logging class Optimizer: binaryname = "" @@ -29,6 +30,17 @@ class Optimizer: def fire_and_forget(self, args): subprocess.Popen(args, stderr=subprocess.STDOUT, stdout=subprocess.PIPE).communicate()[0] + def check_availability(self): + path = os.environ.get("PATH").split(os.pathsep) + + def exists_in_path(prog): + result = filter(lambda x: os.path.exists(os.path.join(x, prog)), path) + return len(result) != 0 + + if (not exists_in_path(self.binaryname)) and (not exists_in_path(self.binaryname + ".exe")): + raise Exception("Optimization program '%s' was not found!" % self.binaryname) + + class NonAtomicOptimizer(Optimizer): def cleanup(self, img): os.rename(img + ".tmp", img) @@ -91,17 +103,6 @@ class optipng(Optimizer, PNGOptimizer): Optimizer.fire_and_forget(self, [self.binaryname, "-o" + str(self.olevel), "-quiet", img]) -def check_programs(optimizers): - path = os.environ.get("PATH").split(os.pathsep) - - def exists_in_path(prog): - result = filter(lambda x: os.path.exists(os.path.join(x, prog)), path) - return len(result) != 0 - - for opt in optimizers: - if (not exists_in_path(opt.binaryname)) and (not exists_in_path(opt.binaryname + ".exe")): - raise Exception("Optimization program '%s' was not found!" % opt.binaryname) - def optimize_image(imgpath, imgformat, optimizers): for opt in optimizers: if imgformat == 'png': diff --git a/overviewer_core/settingsValidators.py b/overviewer_core/settingsValidators.py index 6b53bee..e61a1ab 100644 --- a/overviewer_core/settingsValidators.py +++ b/overviewer_core/settingsValidators.py @@ -7,6 +7,7 @@ import rendermodes import util from optimizeimages import Optimizer from world import UPPER_LEFT, UPPER_RIGHT, LOWER_LEFT, LOWER_RIGHT +import logging class ValidationException(Exception): pass @@ -159,7 +160,6 @@ def validateBGColor(color): def validateOptImg(optimizers): if isinstance(optimizers, (int, long)): from optimizeimages import pngcrush - import logging logging.warning("You're using a deprecated definition of optimizeimg. We'll do what you say for now, but please fix this as soon as possible.") optimizers = [pngcrush()] if not isinstance(optimizers, list): @@ -167,6 +167,9 @@ def validateOptImg(optimizers): for opt in optimizers: if not isinstance(opt, Optimizer): raise ValidationException("Invalid Optimizer!") + + opt.check_availability() + return optimizers def validateTexturePath(path): From 03561dccfad75faec34906c08677c00cb68dfdc6 Mon Sep 17 00:00:00 2001 From: CounterPillow Date: Tue, 6 May 2014 22:31:23 +0200 Subject: [PATCH 21/21] Use check_call(), add workaround for broken pngnq Also -f pngnq to write files, in case something didn't work last render. --- overviewer_core/optimizeimages.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/overviewer_core/optimizeimages.py b/overviewer_core/optimizeimages.py index c877e75..da8d306 100644 --- a/overviewer_core/optimizeimages.py +++ b/overviewer_core/optimizeimages.py @@ -28,7 +28,7 @@ class Optimizer: raise NotImplementedError("I can't let you do that, Dave.") def fire_and_forget(self, args): - subprocess.Popen(args, stderr=subprocess.STDOUT, stdout=subprocess.PIPE).communicate()[0] + subprocess.check_call(args) def check_availability(self): path = os.environ.get("PATH").split(os.pathsep) @@ -46,7 +46,7 @@ class NonAtomicOptimizer(Optimizer): os.rename(img + ".tmp", img) def fire_and_forget(self, args, img): - subprocess.Popen(args, stderr=subprocess.STDOUT, stdout=subprocess.PIPE).communicate()[0] + subprocess.check_call(args) self.cleanup(img) class PNGOptimizer: @@ -76,8 +76,13 @@ class pngnq(NonAtomicOptimizer, PNGOptimizer): else: extension = ".png.tmp" - NonAtomicOptimizer.fire_and_forget(self, [self.binaryname, "-s", str(self.sampling), - "-Q", self.dither, "-e", extension, img], img) + args = [self.binaryname, "-s", str(self.sampling), "-f", "-e", extension, img] + # Workaround for poopbuntu 12.04 which ships an old broken pngnq + if self.dither != "n": + args.insert(1, "-Q") + args.insert(2, self.dither) + + NonAtomicOptimizer.fire_and_forget(self, args, img) class pngcrush(NonAtomicOptimizer, PNGOptimizer): binaryname = "pngcrush"