diff --git a/plugins/python/CMakeLists.txt b/plugins/python/CMakeLists.txt index 3e46581bfd..8b695a3a16 100644 --- a/plugins/python/CMakeLists.txt +++ b/plugins/python/CMakeLists.txt @@ -1,118 +1,119 @@ # Copyright (C) 2012, 2013 Shaheed Haque # Copyright (C) 2013 Alex Turbov # Copyright (C) 2014-2016 Boudewijn Rempt # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES # OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. # IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT # NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. include(CMakeParseArguments) # # Simple helper function to install plugin and related files # having only a name of the plugin... # (just to reduce syntactic noise when a lot of plugins get installed) # function(install_pykrita_plugin name) set(_options) set(_one_value_args) set(_multi_value_args PATTERNS FILE) cmake_parse_arguments(install_pykrita_plugin "${_options}" "${_one_value_args}" "${_multi_value_args}" ${ARGN}) if(NOT name) message(FATAL_ERROR "Plugin filename is not given") endif() if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/${name}.py) install(FILES kritapykrita_${name}.desktop DESTINATION ${DATA_INSTALL_DIR}/krita/pykrita) foreach(_f ${name}.py ${name}.ui ${install_pykrita_plugin_FILE}) if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/${_f}) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/${_f} DESTINATION ${DATA_INSTALL_DIR}/krita/pykrita) endif() endforeach() elseif(IS_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/${name}) install(FILES ${name}/kritapykrita_${name}.desktop DESTINATION ${DATA_INSTALL_DIR}/krita/pykrita) install( DIRECTORY ${name} DESTINATION ${DATA_INSTALL_DIR}/krita/pykrita FILES_MATCHING PATTERN "*.py" PATTERN "*.ui" PATTERN "*.txt" PATTERN "*.csv" PATTERN "*.html" PATTERN "__pycache__*" EXCLUDE PATTERN "tests*" EXCLUDE ) # TODO Is there any way to form a long PATTERN options string # and use it in a single install() call? # NOTE Install specified patterns one-by-one... foreach(_pattern ${install_pykrita_plugin_PATTERNS}) install( DIRECTORY ${name} DESTINATION ${DATA_INSTALL_DIR}/krita/pykrita FILES_MATCHING PATTERN "${_pattern}" PATTERN "__pycache__*" EXCLUDE PATTERN "tests*" EXCLUDE ) endforeach() else() message(FATAL_ERROR "Do not know what to do with ${name}") endif() endfunction() install_pykrita_plugin(hello) install_pykrita_plugin(assignprofiledialog) install_pykrita_plugin(scripter) install_pykrita_plugin(colorspace) install_pykrita_plugin(documenttools) install_pykrita_plugin(filtermanager) install_pykrita_plugin(exportlayers) +install_pykrita_plugin(batch_exporter) #install_pykrita_plugin(highpass) install_pykrita_plugin(tenbrushes) install_pykrita_plugin(tenscripts) #install_pykrita_plugin(palette_docker) # Needs fixing -> bug 405194 install_pykrita_plugin(quick_settings_docker) install_pykrita_plugin(lastdocumentsdocker) # install_pykrita_plugin(scriptdocker) install_pykrita_plugin(comics_project_management_tools) install_pykrita_plugin(krita_script_starter) install_pykrita_plugin(plugin_importer) install_pykrita_plugin(mixer_slider_docker) # if(PYTHON_VERSION_MAJOR VERSION_EQUAL 3) # install_pykrita_plugin(cmake_utils) # install_pykrita_plugin(js_utils PATTERNS "*.json") # install_pykrita_plugin(expand PATTERNS "*.expand" "templates/*.tpl") # endif() install( FILES hello/hello.action tenbrushes/tenbrushes.action tenscripts/tenscripts.action plugin_importer/plugin_importer.action DESTINATION ${DATA_INSTALL_DIR}/krita/actions) install( DIRECTORY libkritapykrita DESTINATION ${DATA_INSTALL_DIR}/krita/pykrita FILES_MATCHING PATTERN "*.py" PATTERN "__pycache__*" EXCLUDE ) diff --git a/plugins/python/batch_exporter/COATools.py b/plugins/python/batch_exporter/COATools.py new file mode 100644 index 0000000000..02214ed5cd --- /dev/null +++ b/plugins/python/batch_exporter/COATools.py @@ -0,0 +1,99 @@ +import os +import json + + +class COAToolsFormat: + def __init__(self, cfg, statusBar): + self.cfg = cfg + self.statusBar = statusBar + self.reset() + + def reset(self): + self.nodes = [] + + def showError(self, msg): + msg, timeout = (self.cfg["error"]["msg"].format(msg), self.cfg["error"]["timeout"]) + self.statusBar.showMessage(msg, timeout) + + def collect(self, node): + print("COAToolsFormat collecting %s" % (node.name)) + self.nodes.append(node) + + def remap(self, oldValue, oldMin, oldMax, newMin, newMax): + if oldMin == newMin and oldMax == newMax: + return oldValue + return (((oldValue - oldMin) * (newMax - newMin)) / (oldMax - oldMin)) + newMin + + def save(self, output_dir=""): + """ + Parses layers configured to export to COA Tools and builds the JSON data + COA Tools need to import the files + """ + # For each top-level node (Group Layer) + cfg = self.cfg + export_dir = output_dir + for wn in self.nodes: + children = wn.children + path = wn.path + + if path != "": + export_dir = path + + print("COAToolsFormat exporting %d items from %s" % (len(children), wn.name)) + try: + if len(children) <= 0: + raise ValueError(wn.name, "has no children to export") + + coa_data = {"name": wn.name, "nodes": []} + print("COAToolsFormat exporting %s to %s" % (wn.name, export_dir)) + for idx, child in enumerate(children): + sheet_meta = dict() + if child.coa != "": + fn, sheet_meta = child.saveCOASpriteSheet(export_dir) + else: + fn = child.saveCOA(export_dir) + + node = child.node + coords = node.bounds().getCoords() + relative_coords = coords + + parent_node = node.parentNode() + parent_coords = parent_node.bounds().getCoords() + relative_coords = [coords[0] - parent_coords[0], coords[1] - parent_coords[1]] + + p_width = parent_coords[2] - parent_coords[0] + p_height = parent_coords[3] - parent_coords[1] + + tiles_x, tiles_y = 1, 1 + if len(sheet_meta) > 0: + tiles_x, tiles_y = sheet_meta["tiles_x"], sheet_meta["tiles_y"] + + coa_entry = { + "children": [], + "frame_index": 0, + "name": child.name, + "node_path": child.name, + "offset": [-p_width / 2, p_height / 2], + "opacity": self.remap(node.opacity(), 0, 255, 0, 1), + "pivot_offset": [0.0, 0.0], + "position": relative_coords, + "resource_path": fn.replace( + export_dir + os.path.sep + cfg["outDir"] + os.path.sep, "" + ), + "rotation": 0.0, + "scale": [1.0, 1.0], + "tiles_x": tiles_x, + "tiles_y": tiles_y, + "type": "SPRITE", + "z": idx - len(children) + 1, + } + coa_data["nodes"].append(coa_entry) + + json_data = json.dumps(coa_data, sort_keys=True, indent=4, separators=(",", ": ")) + with open( + export_dir + os.path.sep + cfg["outDir"] + os.path.sep + wn.name + ".json", "w" + ) as fh: + fh.write(json_data) + + except ValueError as e: + self.showError(e) diff --git a/plugins/python/batch_exporter/Config.py b/plugins/python/batch_exporter/Config.py new file mode 100644 index 0000000000..a7b31281dc --- /dev/null +++ b/plugins/python/batch_exporter/Config.py @@ -0,0 +1,15 @@ +import re +from collections import OrderedDict + + +CONFIG = { + "outDir": "export", + "rootPat": r"^root", + "sym": r"[^a-zA-Z0-9_-]", + "error": {"msg": "ERROR: {}", "timeout": 8000}, + "done": {"msg": "DONE: {}", "timeout": 5000}, + "delimiters": OrderedDict((("assign", "="), ("separator", ","))), # yapf: disable + "meta": {"c": [""], "e": ["png"], "m": [0], "p": [""], "s": [100]}, +} +CONFIG["rootPat"] = re.compile(CONFIG["rootPat"]) +CONFIG["sym"] = re.compile(CONFIG["sym"]) diff --git a/plugins/python/batch_exporter/Infrastructure.py b/plugins/python/batch_exporter/Infrastructure.py new file mode 100644 index 0000000000..90ff682514 --- /dev/null +++ b/plugins/python/batch_exporter/Infrastructure.py @@ -0,0 +1,363 @@ +import os +import re +from collections import OrderedDict +from functools import partial +from itertools import groupby, product, starmap, tee + +from krita import Krita +from PyQt5.QtCore import QSize +from PyQt5.QtGui import QColor, QImage, QPainter + +from .Utils import flip, kickstart +from .Utils.Export import exportPath, sanitize +from .Utils.Tree import pathFS + +KI = Krita.instance() + + +def nodeToImage(wnode): + """ + Returns an QImage 8-bit sRGB + """ + SRGB_PROFILE = "sRGB-elle-V2-srgbtrc.icc" + [x, y, w, h] = wnode.bounds + + is_srgb = ( + wnode.node.colorModel() == "RGBA" + and wnode.node.colorDepth() == "U8" + and wnode.node.colorProfile().lower() == SRGB_PROFILE.lower() + ) + + if is_srgb: + pixel_data = wnode.node.projectionPixelData(x, y, w, h).data() + else: + temp_node = wnode.node.duplicate() + temp_node.setColorSpace("RGBA", "U8", SRGB_PROFILE) + pixel_data = temp_node.projectionPixelData(x, y, w, h).data() + + return QImage(pixel_data, w, h, QImage.Format_ARGB32) + + +def expandAndFormat(img, margin=0, is_jpg=False): + """ + Draws the image with transparent background if `is_jpg == False`, otherwise with a white background. + It's done in a single function, to avoid creating extra images + """ + if not margin and not is_jpg: + return img + corner = QSize(margin, margin) + white = QColor(255, 255, 255) if is_jpg else QColor(255, 255, 255, 0) + canvas = QImage( + img.size() + corner * 2, QImage.Format_RGB32 if is_jpg else QImage.Format_ARGB32 + ) + canvas.fill(white) + p = QPainter(canvas) + p.drawImage(margin, margin, img) + return canvas + + +class WNode: + """ + Wrapper around Krita's Node class, that represents a layer. + Adds support for export metadata and methods to export the layer + based on its metadata. + See the meta property for a list of supported metadata. + """ + + def __init__(self, cfg, node): + self.cfg = cfg + self.node = node + + def __bool__(self): + return bool(self.node) + + @property + def name(self): + a = self.cfg["delimiters"]["assign"] + name = self.node.name() + name = name.split() + name = filter(lambda n: a not in n, name) + name = "_".join(name) + return sanitize(name) + + @property + def meta(self): + a, s = self.cfg["delimiters"].values() + meta = self.node.name().strip().split(a) + meta = starmap(lambda fst, snd: (fst[-1], snd.split()[0]), zip(meta[:-1], meta[1:])) + meta = filter(lambda m: m[0] in self.cfg["meta"].keys(), meta) + meta = OrderedDict((k, v.lower().split(s)) for k, v in meta) + meta.update({k: list(map(int, v)) for k, v in meta.items() if k in "ms"}) + meta.setdefault("c", self.cfg["meta"]["c"]) # coa_tools + meta.setdefault("e", self.cfg["meta"]["e"]) # extension + meta.setdefault("m", self.cfg["meta"]["m"]) # margin + meta.setdefault("p", self.cfg["meta"]["p"]) # path + meta.setdefault("s", self.cfg["meta"]["s"]) # scale + return meta + + @property + def path(self): + return self.meta["p"][0] + + @property + def coa(self): + return self.meta["c"][0] + + @property + def parent(self): + return WNode(self.cfg, self.node.parentNode()) + + @property + def children(self): + return [WNode(self.cfg, n) for n in self.node.childNodes()] + + @property + def type(self): + return self.node.type() + + @property + def position(self): + bounds = self.node.bounds() + return bounds.x(), bounds.y() + + @property + def bounds(self): + bounds = self.node.bounds() + return bounds.x(), bounds.y(), bounds.width(), bounds.height() + + @property + def size(self): + bounds = self.node.bounds() + return bounds.width(), bounds.height() + + def hasDestination(self): + return "d=" in self.node.name() + + def isExportable(self): + return ( + self.isPaintLayer() or self.isGroupLayer() or self.isFileLayer() or self.isVectorLayer() + ) # yapf: disable + + def isMarked(self): + return "e=" in self.node.name() + + def isLayer(self): + return "layer" in self.type + + def isMask(self): + return "mask" in self.type + + def isPaintLayer(self): + return self.type == "paintlayer" + + def isGroupLayer(self): + return self.type == "grouplayer" + + def isFileLayer(self): + return self.type == "filelayer" + + def isFilterLayer(self): + return self.type == "filterlayer" + + def isFillLayer(self): + return self.type == "filllayer" + + def isCloneLayer(self): + return self.type == "clonelayer" + + def isVectorLayer(self): + return self.type == "vectorlayer" + + def isTransparencyMask(self): + return self.type == "transparencyMask" + + def isFilterMask(self): + return self.type == "filtermask" + + def isTransformMask(self): + return self.type == "transformmask" + + def isSelectionMask(self): + return self.type == "selectionmask" + + def isColorizeMask(self): + return self.type == "colorizemask" + + def rename(self, pattern): + """ + Renames the layer, scanning for patterns in the user's input trying to preserve metadata. + Patterns have the form meta_name=value, + E.g. s=50,100 to tell the tool to export two copies of the layer at 50% and 100% of its size + This function will only replace or update corresponding metadata. + If the rename string starts with a name, the layer's name will change to that. + """ + patterns = pattern.strip().split() + a = self.cfg["delimiters"]["assign"] + + patterns = map(partial(flip(str.split), a), patterns) + + success, patterns = tee(patterns) + success = map(lambda p: len(p) == 2, success) + if not all(success): + raise ValueError("malformed pattern.") + + key = lambda p: p[0] in self.cfg["meta"].keys() + patterns = sorted(patterns, key=key) + patterns = groupby(patterns, key) + + newName = self.node.name() + for k, ps in patterns: + for p in ps: + how = ( + "replace" + if k is False + else "add" + if p[1] != "" and "{}{}".format(p[0], a) not in newName + else "subtract" + if p[1] == "" + else "update" + ) + pat = ( + p + if how == "replace" + else (r"$", r" {}{}{}".format(p[0], a, p[1])) + if how == "add" + else ( + r"\s*({}{})[\w,]+\s*".format(p[0], a), + " " if how == "subtract" else r" \g<1>{} ".format(p[1]), + ) + ) + newName = re.sub(pat[0], pat[1], newName).strip() + self.node.setName(newName) + + def save(self, dirname=""): + """ + Transform Node to a QImage + processes the image, names it based on metadata, and saves the image to the disk. + """ + img = nodeToImage(self) + meta = self.meta + margin, scale = meta["m"], meta["s"] + extension, path = meta["e"], meta["p"][0] + + dirPath = ( + exportPath(self.cfg, path, dirname) + if path + else exportPath(self.cfg, pathFS(self.parent), dirname) + ) + os.makedirs(dirPath, exist_ok=True) + + def append_name(path, name, scale, margin, extension): + """ + Appends a formatted name to the path argument + Returns the full path with the file + """ + meta_s = self.cfg["meta"]["s"][0] + out = os.path.join(path, name) + out += "_@{}x".format(scale / 100) if scale != meta_s else "" + out += "_m{:03d}".format(margin) if margin else "" + out += "." + extension + return out + + it = product(scale, margin, extension) + # Below: scale for scale, margin for margin, extension for extension + it = starmap( + lambda scale, margin, extension: ( + scale, + margin, + extension, + append_name(dirPath, self.name, scale, margin, extension), + ), + it, + ) + it = starmap( + lambda scale, margin, extension, path: ( + [int(1e-2 * wh * scale) for wh in self.size], + 100 - scale != 0, + margin, + extension, + path, + ), + it, + ) + it = starmap( + lambda width_height, should_scale, margin, extension, path: ( + img.smoothScaled(*width_height) if should_scale else img, + margin, + extension in ("jpg", "jpeg"), + path, + ), + it, + ) + it = starmap( + lambda image, margin, is_jpg, path: ( + expandAndFormat(image, margin, is_jpg=is_jpg), + path, + is_jpg + ), + it, + ) + it = starmap(lambda image, path, is_jpg: image.save(path, quality=90 if is_jpg else -1), it) + kickstart(it) + + def saveCOA(self, dirname=""): + img = nodeToImage(self) + meta = self.meta + path, extension = "", meta["e"] + + dirPath = ( + exportPath(self.cfg, path, dirname) + if path + else exportPath(self.cfg, pathFS(self.parent), dirname) + ) + os.makedirs(dirPath, exist_ok=True) + ext = extension[0] + path = "{}{}".format(os.path.join(dirPath, self.name), ".{e}") + path = path.format(e=ext) + is_jpg = ext in ("jpg", "jpeg") + if is_jpg in ("jpg", "jpeg"): + img = expandAndFormat(img, is_jpg=is_jpg) + img.save(path, quality=90 if is_jpg else -1) + + return path + + def saveCOASpriteSheet(self, dirname=""): + """ + Generate a vertical sheet of equaly sized frames + Each child of self is pasted to a master sheet + """ + images = self.children + tiles_x, tiles_y = 1, len(images) # Length of vertical sheet + image_width, image_height = self.size # Target frame size + sheet_width, sheet_height = (image_width, image_height * tiles_y) # Sheet dimensions + + sheet = QImage(sheet_width, sheet_height, QImage.Format_ARGB32) + sheet.fill(QColor(255, 255, 255, 0)) + painter = QPainter(sheet) + + p_coord_x, p_coord_y = self.position + for count, image in enumerate(images): + coord_x, coord_y = image.position + coord_rel_x, coord_rel_y = coord_x - p_coord_x, coord_y - p_coord_y + + painter.drawImage( + coord_rel_x, image_height * count + coord_rel_y, nodeToImage(image), + ) + + meta = self.meta + path, extension = "", meta["e"] + + dirPath = ( + exportPath(self.cfg, path, dirname) + if path + else exportPath(self.cfg, pathFS(self.parent), dirname) + ) + os.makedirs(dirPath, exist_ok=True) + path = "{}{}".format(os.path.join(dirPath, self.name), ".{e}") + path = path.format(e=extension[0]) + is_jpg = extension in ("jpg", "jpeg") + if is_jpg: + sheet = expandAndFormat(sheet, is_jpg=True) + sheet.save(path, quality=90 if is_jpg else -1) + + return path, {"tiles_x": tiles_x, "tiles_y": tiles_y} diff --git a/plugins/python/batch_exporter/Manual.html b/plugins/python/batch_exporter/Manual.html new file mode 100644 index 0000000000..d3cb2f87a0 --- /dev/null +++ b/plugins/python/batch_exporter/Manual.html @@ -0,0 +1,57 @@ + + + + + + + Manual + + + +

Batch Exporter: Krita Plugin for Game Developers and Graphic Designers

+

Free Krita plugin for designers, game artists and digital artists to work more productively:

+
    +
  • Batch export assets to multiple sizes, file types, and custom paths. Supports jpg and png.
  • +
  • Rename layers quickly with the smart rename tool
  • +
+

Batch Export Layers

+

Batch Exporter exports individual layers to image files based on metadata in the layer name. The supported options are:

+
    +
  • [e=jpg,png] - supported export image extensions
  • +
  • [s=20,50,100,150] - size in %
  • +
  • [p=path/to/custom/export/directory] - custom output path. Paths can be absolute or relative to the Krita document.
  • +
  • [m=20,30,100] - extra margin in px. The layer is trimmed to the smallest bounding box by default. This option adds extra padding around the layer.
  • +
+

A typical layer name with metadata looks like: CharacterTorso e=png m=30 s=50,100. This exports the layer as two images, with an added padding of 30 pixels on each side: CharacterTorso_s100_m030.png, and CharacterTorso_s050_m030.png, a copy of the layer scaled down to half the original size.

+

All the metadata tags are optional. Each tag can contain one or multiple options separated by comma ,. Write e=jpg to export the layer to jpg only and e=jpg,png to export the layer twice, as a jpg and as a png file. Note that the other tag, p= has been left out. Below we describe how the plugin works.

+

Getting Started

+

Batch Exporter gives two options to batch export layers: Export All Layers or Export Selected Layers.

+

Export All Layers only takes layers with the e=extension[s] tag into account. For example, if the layer name is LeftArm e=png s=50,100, Export All Layers will take it into account. If the layer name is LeftArm s=50,100, it will not be exported with this option.

+

Export Selected Layers exports all selected layers regardless of the tags.

+

By default, the plugin exports the images in an export folder next to your Krita document. The export follows the structure of your layer stack. The group layers become directories and other layers export as files.

+
+

Supported layer types: paint, vector, group & file layers.

+
+

Smart Layer Rename tool

+

Say we have this Krita document structure:

+
GodetteGroupLayer
+  +-- HeadGroupLayer
+    +-- Hair
+    +-- Eyes
+    +-- Rest
+  +-- Torso
+  +-- LeftArm
+  +-- RightArm
+Background
+

If you want to export GodetteGroupLayer, HeadGroupLayer, Torso, LeftArm, and RightArm, but not the other layers, you can select these layers and write the following in the Update Layer Name text box: e=png s=40,100 and press Enter. In this example, Art Tools will export two copies of the selected layers to png at 40% and 100% scale. This is what s=40,100 does.

+

Say that we made a mistake: we want to export to 50% instead of 40%. Select the layers once more and write s=50,100 in the text box. Press Enter. This will update the size tag and leave e=png untouched.

+

The tool can do more than add and update meta tags. If you want to remove GroupLayer from the name on GodetteGroupLayer and HeadGroupLayer, select them and write GroupLayer= in the text box. Press Enter and the GroupLayer text will disappear from the selected layers.

+

The = tells the tool to search and replace. this=[that] will replace this with [that]. If you don’t write anything after the equal sign, the tool will erase the text you searched for.

+

The rename tool is smarter with meta tags. Writing e= will remove the extension tag entirely. For example, Godete e=png s=50,100 will become Godette s=50,100.

+ + diff --git a/plugins/python/batch_exporter/Manual.md b/plugins/python/batch_exporter/Manual.md new file mode 100644 index 0000000000..c9d32fcba6 --- /dev/null +++ b/plugins/python/batch_exporter/Manual.md @@ -0,0 +1,151 @@ +# Batch Exporter: Krita Plugin for Game Developers and Graphic Designers + +Free Krita plugin for designers, game artists and digital artists to work more +productively: + +- Batch export assets to multiple sizes, file types, and custom paths. Supports + `jpg` and `png`. +- Rename layers quickly with the smart rename tool + +## Batch Export Layers + +Batch Exporter exports individual layers to image files based on metadata in +the layer name. The supported options are: + +- `[e=jpg,png]` - supported export image extensions +- `[s=20,50,100,150]` - size in `%` +- `[p=path/to/custom/export/directory]` - custom output path. + Paths can be absolute or relative to the Krita document. +- `[m=20,30,100]` - extra margin in `px`. The layer is trimmed to the + smallest bounding box by default. This option adds extra padding around the + layer. + +A typical layer name with metadata looks like: `CharacterTorso e=png m=30 +s=50,100`. This exports the layer as two images, with an added padding of 30 pixels +on each side: `CharacterTorso_s100_m030.png`, and `CharacterTorso_s050_m030.png`, +a copy of the layer scaled down to half the original size. + +All the metadata tags are optional. Each tag can contain one or multiple options +separated by comma `,`. Write `e=jpg` to export the layer to `jpg` only and +`e=jpg,png` to export the layer twice, as a `jpg` and as a `png` file. Note that +the other tag, `p=` has been left out. Below we describe how the plugin works. + +## Getting Started + +Batch Exporter gives two options to batch export layers: `Export All Layers` +or `Export Selected Layers`. + +`Export All Layers` only takes layers with the `e=extension[s]` tag into +account. For example, if the layer name is `LeftArm e=png s=50,100`, `Export All +Layers` will take it into account. If the layer name is `LeftArm s=50,100`, it +will not be exported with this option. + +`Export Selected Layers` exports all selected layers regardless of the tags. + +By default, the plugin exports the images in an `export` folder next to your +Krita document. The export follows the structure of your layer stack. The group +layers become directories and other layers export as files. + +> **Supported layer types:** paint, vector, group & file layers. + +## Smart Layer Rename tool + +Say we have this Krita document structure: + +``` +GodetteGroupLayer + +-- HeadGroupLayer + +-- Hair + +-- Eyes + +-- Rest + +-- Torso + +-- LeftArm + +-- RightArm +Background +``` + +If you want to export `GodetteGroupLayer`, `HeadGroupLayer`, `Torso`, `LeftArm`, +and `RightArm`, but not the other layers, you can select these layers and write +the following in the `Update Layer Name` text box: `e=png s=40,100` and press +Enter. In this example, Art Tools will export two copies of the +selected layers to png at `40%` and `100%` scale. This is what `s=40,100` does. + +Say that we made a mistake: we want to export to `50%` instead of `40%`. Select +the layers once more and write `s=50,100` in the text box. Press +Enter. This will update the size tag and leave `e=png` untouched. + +The tool can do more than add and update meta tags. If you want to remove +`GroupLayer` from the name on `GodetteGroupLayer` and `HeadGroupLayer`, select them +and write `GroupLayer=` in the text box. Press Enter and the +`GroupLayer` text will disappear from the selected layers. + +The `=` tells the tool to search and replace. `this=[that]` will replace `this` +with `[that]`. If you don't write anything after the equal sign, the tool will +erase the text you searched for. + +The rename tool is smarter with meta tags. Writing `e=` will remove the +extension tag entirely. For example, `Godete e=png s=50,100` will become +`Godette s=50,100`. + +## COA Tools format + +The exporter will generate the necessary sprite contents and metadata file for +easy import in COA Tools / Blender. + +If you want to export your krita document to COA Tools format, +simply click the `Document` button under COA Tools. + +If you want to export multiple or specific COA Tool documents from one Krita document +(if you have e.g. multiple characters in one Krita document), +you can do so by selecting a Group Layer to serve as root for each COA Tool export +you want done. + +### Example +You want to export two characters from the same Krita document in one go +``` +Root + +-- Robot (Group Layer) <-- Select this layer + | +-- Head + | +-- Body + | +-- Legs + | + +-- Sketches + | +-- ... + | + +-- Minion (Group Layer) <-- ... and this layer + | +-- Hat + | +-- Head + | + Background +``` +Once the Group Layers are selected you push "COA Tools -> Selected Layers". + +Each export root supports the following metadata: +- `[p=path/to/custom/export/directory]` - custom output path. + Paths can be absolute or relative to the Krita document. + +Each child node of an export root supports the following metadata: +- `[e=jpg,png]` - supported export image extensions + +Generating frames to a sprite sheet from a Group Layer is also possible. +Simply mark the layer containing each frame you want in the sheet with a +`c=sheet` and each child layer will act as one frame you can switch when +Working with COA Tools in Blender. + +### Example +You want to export a character from the document, and be +able to switch between each state of e.g. the mouth: +``` +Root + +-- Robot (Group Layer) <-- If this is the export root + | +-- Mouth States c=sheet <-- ... mark this layer + | | +-- Open + | | +-- Half Open + | | +-- Closed + | | + | +-- Head + | +-- Body + | +-- Legs + | + Background +``` diff --git a/plugins/python/batch_exporter/Utils/Export.py b/plugins/python/batch_exporter/Utils/Export.py new file mode 100644 index 0000000000..0867267a7b --- /dev/null +++ b/plugins/python/batch_exporter/Utils/Export.py @@ -0,0 +1,19 @@ +import os +import re +from ..Config import CONFIG + + +def exportPath(cfg, path, dirname=""): + return os.path.join(dirname, subRoot(cfg, path)) + + +def subRoot(cfg, path): + patF, patR = cfg["rootPat"], CONFIG["outDir"] + return re.sub(patF, patR, path, count=1) + + +def sanitize(path): + ps = path.split(os.path.sep) + ps = map(lambda p: re.sub(CONFIG["sym"], "_", p), ps) + ps = os.path.sep.join(ps) + return ps diff --git a/plugins/python/batch_exporter/Utils/Tree.py b/plugins/python/batch_exporter/Utils/Tree.py new file mode 100644 index 0000000000..b5ec25076e --- /dev/null +++ b/plugins/python/batch_exporter/Utils/Tree.py @@ -0,0 +1,152 @@ +import os +from itertools import chain + +def iterPre(node, maxDepth=-1): + """ + Visit nodes in pre order. + + Parameters + ---------- + node: Node + maxDepth: int + Maximum depth level at which traversal will stop. + + Returns + ------- + out: iter(Node) + """ + + def go(nodes, depth=0): + for n in nodes: + yield n + # recursively call the generator if depth < maxDepth + it = go(n.children, depth + 1) if maxDepth == -1 or depth < maxDepth else iter() + yield from it + + return go([node]) + + +def iterLevel(node, maxDepth=-1): + """ + Visit nodes in level order. + + Parameters + ---------- + node: Node + maxDepth: int + Maximum depth level at which traversal will stop. + + Returns + ------- + out: iter(Node) + """ + + def go(nodes, depth=0): + yield from nodes + it = map(lambda n: go(n.children, depth + 1), nodes) + it = chain(*it) if maxDepth == -1 or depth < maxDepth else iter() + yield from it + + return go([node]) + + +def iterLevelGroup(node, maxDepth=-1): + """ + Visit nodes in level order just like `iterLevel`, but group nodes per level in an iterator. + + Parameters + ---------- + node: Node + maxDepth: int + Maximum depth level at which traversal will stop. + + Returns + ------- + out: iter(iter(Node)) + Returns an iterator that holds an iterator for each depth level. + """ + + def go(nodes, depth=0): + yield iter(nodes) + it = map(lambda n: go(n.children, depth + 1), nodes) + it = chain(*it) if maxDepth == -1 or depth < maxDepth else iter() + yield from filter(None, it) + + return go([node]) + + +def iterPost(node, maxDepth=-1): + """ + Visit nodes in post order. + + Parameters + ---------- + node: Node + maxDepth: int + Maximum depth level at which traversal will stop. + + Returns + ------- + out: iter(Node) + """ + + def go(nodes, depth=0): + for n in nodes: + it = go(n.children, depth + 1) if maxDepth == -1 or depth < maxDepth else iter() + yield from it + yield n + + return go([node]) + + +def path(node): + """ + Get the path of the given node. + + Parameters + ---------- + node: Node + + Return + ------ + out: list(Node) + The path of nodes going through all the parents to the given node. + """ + + def go(n, acc=[]): + acc += [n] + n.parent and go(n.parent, acc) + return reversed(acc) + + return list(go(node)) + + +def pathFS(node): + """ + Get the path of the given node just like `path`, but returns a OS filesystem path based on + node names. + + Parameters + ---------- + node: Node + A node that has a `name` method. + + Return + ------ + out: str + The path of nodes going through all the parents to the given node in filesystem-compatile + string format. + """ + it = filter(lambda n: n.parent, path(node)) + it = map(lambda n: n.name, it) + return os.path.join('', *it) + + +def iterDirs(node): + it = iterPre(node) + it = filter(lambda n: n.isGroupLayer(), it) + it = filter( + lambda n: any(i.isExportable() for i in chain(*map(lambda c: iterPre(c), n.children))), it + ) + it = map(pathFS, it) + return it diff --git a/plugins/python/batch_exporter/Utils/__init__.py b/plugins/python/batch_exporter/Utils/__init__.py new file mode 100644 index 0000000000..b9a90cd743 --- /dev/null +++ b/plugins/python/batch_exporter/Utils/__init__.py @@ -0,0 +1,9 @@ +from collections import deque + + +def flip(f): + return lambda *a: f(*reversed(a)) + + +def kickstart(it): + deque(it, maxlen=0) diff --git a/plugins/python/batch_exporter/__init__.py b/plugins/python/batch_exporter/__init__.py new file mode 100644 index 0000000000..bd7687d505 --- /dev/null +++ b/plugins/python/batch_exporter/__init__.py @@ -0,0 +1,3 @@ +from .batch_exporter import registerDocker # noqa + +registerDocker() diff --git a/plugins/python/batch_exporter/batch_exporter.py b/plugins/python/batch_exporter/batch_exporter.py new file mode 100644 index 0000000000..fc13e1e481 --- /dev/null +++ b/plugins/python/batch_exporter/batch_exporter.py @@ -0,0 +1,194 @@ +""" +GDQuest Batch Exporter +----------------- +Batch export art assets from Krita using layer metadata. +Updates and reads metadata in Krita's layer names, and uses it to smartly process and export layers. +Export to the Blender Cut-Out Animation tools for modular 2d game animation. +Licensed under the GNU GPL v3.0 terms +""" + +from functools import partial +from krita import DockWidget, DockWidgetFactory, DockWidgetFactoryBase, Krita +from PyQt5.QtWidgets import ( + QPushButton, + QStatusBar, + QLabel, + QLineEdit, + QHBoxLayout, + QVBoxLayout, + QGroupBox, + QWidget, +) +import os +from .Config import CONFIG +from .Infrastructure import WNode +from .COATools import COAToolsFormat +from .Utils import kickstart, flip +from .Utils.Tree import iterPre + +KI = Krita.instance() + + +def ensureRGBAU8(doc): + ensured = doc.colorModel() == "RGBA" and doc.colorDepth() == "U8" + if not ensured: + raise ValueError("only RGBA 8-bit depth supported!") + + +def exportAllLayers(cfg, statusBar): + msg, timeout = (cfg["done"]["msg"].format("Exported all layers."), cfg["done"]["timeout"]) + try: + doc = KI.activeDocument() + + root = doc.rootNode() + root = WNode(cfg, root) + + dirName = os.path.dirname(doc.fileName()) + it = filter(lambda n: n.isExportable() and n.isMarked(), iterPre(root)) + it = map(partial(flip(WNode.save), dirName), it) + kickstart(it) + except ValueError as e: + msg, timeout = cfg["error"]["msg"].format(e), cfg["error"]["timeout"] + statusBar.showMessage(msg, timeout) + + +def exportSelectedLayers(cfg, statusBar): + msg, timeout = (cfg["done"]["msg"].format("Exported selected layers."), cfg["done"]["timeout"]) + try: + doc = KI.activeDocument() + + dirName = os.path.dirname(doc.fileName()) + nodes = KI.activeWindow().activeView().selectedNodes() + it = map(partial(WNode, cfg), nodes) + it = map(partial(flip(WNode.save), dirName), it) + kickstart(it) + except ValueError as e: + msg, timeout = cfg["error"]["msg"].format(e), cfg["error"]["timeout"] + statusBar.showMessage(msg, timeout) + + +def exportCOATools(mode, cfg, statusBar): + msg, timeout = ( + cfg["done"]["msg"].format("Exported %s layers to COA Tools format." % (mode)), + cfg["done"]["timeout"], + ) + try: + doc = KI.activeDocument() + ensureRGBAU8(doc) + + coat_format = COAToolsFormat(cfg, statusBar) + dirName = os.path.dirname(doc.fileName()) + nodes = KI.activeWindow().activeView().selectedNodes() + + # If mode is document or no nodes are selected, use document root + if mode == "document" or len(nodes) == 0: + nodes = [doc.rootNode()] + + it = map(partial(WNode, cfg), nodes) + # By convention all selected nodes should be Group Layers + # This is to represent a logical root for each export in COATools format + it = filter(lambda n: n.isGroupLayer(), it) + it = map(coat_format.collect, it) + kickstart(it) + coat_format.save(dirName) + + except ValueError as e: + msg, timeout = cfg["error"]["msg"].format(e), cfg["error"]["timeout"] + statusBar.showMessage(msg, timeout) + + +def renameLayers(cfg, statusBar, lineEdit): + msg, timeout = (cfg["done"]["msg"].format("Renaming successful!"), cfg["done"]["timeout"]) + try: + nodes = KI.activeWindow().activeView().selectedNodes() + it = map(partial(WNode, cfg), nodes) + it = map(partial(flip(WNode.rename), lineEdit.text()), it) + kickstart(it) + except ValueError as e: + msg, timeout = cfg["error"]["msg"].format(e), cfg["error"]["timeout"] + statusBar.showMessage(msg, timeout) + + +class GameArtTools(DockWidget): + title = "Batch Exporter" + + def __init__(self): + super().__init__() + KI.setBatchmode(True) + self.setWindowTitle(self.title) + self.createInterface() + + def createInterface(self): + uiContainer = QWidget(self) + + exportLabel = QLabel("Export") + exportAllLayersButton = QPushButton("All Layers") + exportSelectedLayersButton = QPushButton("Selected Layers") + renameLabel = QLabel("Update Name and Metadata") + renameLineEdit = QLineEdit() + renameButton = QPushButton("Update") + statusBar = QStatusBar() + + exportLabel.setToolTip("Export individual images") + exportAllLayersButton.setToolTip("Export all layers with metadata") + exportSelectedLayersButton.setToolTip("Export selected layers only") + renameButton.setToolTip("Batch update selected layer names and metadata") + + # COA Tools GroupBox + coaToolsGroupBox = QGroupBox("COA Tools") + coaToolsHBoxLayout = QHBoxLayout() + coaToolsExportSelectedLayersButton = QPushButton("Selected Layers") + coaToolsExportDocumentButton = QPushButton("Document") + + coaToolsGroupBox.setToolTip("Blender Cut-Out Animation Tools") + coaToolsExportSelectedLayersButton.setToolTip("Export selected layers only") + coaToolsExportDocumentButton.setToolTip("Export all layers with metadata") + + coaToolsHBoxLayout.addWidget(coaToolsExportDocumentButton) + coaToolsHBoxLayout.addWidget(coaToolsExportSelectedLayersButton) + coaToolsGroupBox.setLayout(coaToolsHBoxLayout) + + vboxlayout = QVBoxLayout() + vboxlayout.addWidget(exportLabel) + vboxlayout.addWidget(exportAllLayersButton) + vboxlayout.addWidget(exportSelectedLayersButton) + + vboxlayout.addWidget(coaToolsGroupBox) + vboxlayout.addWidget(renameLabel) + vboxlayout.addWidget(renameLineEdit) + + hboxlayout = QHBoxLayout() + hboxlayout.addStretch() + hboxlayout.addWidget(renameButton) + + vboxlayout.addLayout(hboxlayout) + vboxlayout.addStretch() + vboxlayout.addWidget(statusBar) + + uiContainer.setLayout(vboxlayout) + self.setWidget(uiContainer) + + exportSelectedLayersButton.released.connect( + partial(exportSelectedLayers, CONFIG, statusBar) + ) + exportAllLayersButton.released.connect(partial(exportAllLayers, CONFIG, statusBar)) + coaToolsExportSelectedLayersButton.released.connect( + partial(exportCOATools, "selected", CONFIG, statusBar) + ) + coaToolsExportDocumentButton.released.connect( + partial(exportCOATools, "document", CONFIG, statusBar) + ) + renameLineEdit.returnPressed.connect( + partial(renameLayers, CONFIG, statusBar, renameLineEdit) + ) + renameButton.released.connect(partial(renameLayers, CONFIG, statusBar, renameLineEdit)) + + def canvasChanged(self, canvas): + pass + + +def registerDocker(): + docker = DockWidgetFactory( + "pykrita_gdquest_art_tools", DockWidgetFactoryBase.DockRight, GameArtTools + ) + KI.addDockWidgetFactory(docker) diff --git a/plugins/python/batch_exporter/kritapykrita_batch_exporter.desktop b/plugins/python/batch_exporter/kritapykrita_batch_exporter.desktop new file mode 100644 index 0000000000..bf03b3a2b0 --- /dev/null +++ b/plugins/python/batch_exporter/kritapykrita_batch_exporter.desktop @@ -0,0 +1,15 @@ +[Desktop Entry] +Type=Service +ServiceTypes=Krita/PythonPlugin +X-KDE-Library=batch_exporter +X-Krita-Manual=Manual.html +X-Python-2-Compatible=false +Name=Batch Exporter +Name[en_GB]=Batch Exporter +Name[es]=Exportador por Lotes +Name[pt]=Exportador de Lote +Name[pt_BR]=Exportador de Lote +Name[ja]=バッチ処理書き出す +Comment=Smart export tool that uses layer names to scale and (re-)export art assets in batches fast +Comment[en_GB]=Smart export tool that uses layer names to scale and (re-)export art assets in batches fast +Comment[es]=Herramienta de exportacíon por lotes inteligente que usa los nombres de las capas para adaptar el tamaño y exportar imagenes por lote rápido diff --git a/plugins/python/batch_exporter/pyproject.toml b/plugins/python/batch_exporter/pyproject.toml new file mode 100644 index 0000000000..440365c381 --- /dev/null +++ b/plugins/python/batch_exporter/pyproject.toml @@ -0,0 +1,9 @@ +[tool.black] +line-length=100 +include = '\.py$' +exclude = ''' +/( + \.git + | Dependencies +)/ +''' \ No newline at end of file