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..bc50d151ac --- /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"\W", + "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/GDQuestBatchExporter.py b/plugins/python/batch_exporter/GDQuestBatchExporter.py new file mode 100644 index 0000000000..708fa44fed --- /dev/null +++ b/plugins/python/batch_exporter/GDQuestBatchExporter.py @@ -0,0 +1,187 @@ +""" +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() + ensureRGBAU8(doc) + + 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() + ensureRGBAU8(doc) + + 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 Layer Meta/Name") + renameLineEdit = QLineEdit() + renameButton = QPushButton("Update") + statusBar = QStatusBar() + + # COA Tools GroupBox + coaToolsGroupBox = QGroupBox("COA Tools") + coaToolsHBoxLayout = QHBoxLayout() + coaToolsExportSelectedLayersButton = QPushButton("Selected Layers") + coaToolsExportDocumentButton = QPushButton("Document") + + 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/Infrastructure.py b/plugins/python/batch_exporter/Infrastructure.py new file mode 100644 index 0000000000..3253cc6e36 --- /dev/null +++ b/plugins/python/batch_exporter/Infrastructure.py @@ -0,0 +1,334 @@ +from collections import OrderedDict +from functools import partial +from itertools import groupby, product, starmap, tee +import os +import re +from krita import Krita +from PIL import Image, ImageOps + +from .Utils import kickstart, flip +from .Utils.Export import sanitize, exportPath +from .Utils.Tree import pathFS + +KI = Krita.instance() + + +def dataToPIL(wnode): + img = wnode.node.projectionPixelData(*wnode.bounds).data() + img = Image.frombytes("RGBA", wnode.size, img, "raw", "BGRA", 0, 1) + return img + + +def toJPEG(img): + newImg = Image.new("RGBA", img.size, 4 * (255,)) + newImg.alpha_composite(img) + return newImg.convert("RGB") + + +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=""): + """ + Extracts metadata from the node, converts the image data to PIL to process, + processes the image, names it based on metadata, and saves the image to the disk. + """ + img = dataToPIL(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.resize(width_height, Image.LANCZOS) if should_scale else img, + margin, + extension, + path, + ), + it, + ) + it = starmap( + lambda image, margin, extension, path: ( + ImageOps.expand(image, margin, (255, 255, 255, 0)), + extension, + path, + ), + it, + ) + it = starmap( + lambda image, extension, path: ( + toJPEG(image) if extension in ("jpg", "jpeg") else image, + path, + ), + it, + ) + it = starmap(lambda image, path: image.save(path), it) + kickstart(it) + + def saveCOA(self, dirname=""): + img = dataToPIL(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) + path = "{}{}".format(os.path.join(dirPath, self.name), ".{e}") + path = path.format(e=extension[0]) + if extension in ("jpg", "jpeg"): + toJPEG(img) + img.save(path) + + 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 = Image.new( + mode="RGBA", size=(sheet_width, sheet_height), color=(0, 0, 0, 0) + ) # fully transparent + + 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 + + sheet.paste(dataToPIL(image), (coord_rel_x, image_height * count + coord_rel_y)) + + 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]) + if extension in ("jpg", "jpeg"): + toJPEG(sheet) + sheet.save(path) + + 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 @@ + + +
+ + + +Free Krita plugin for designers, game artists and digital artists to work more productively:
+jpg
and png
.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.
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.
+
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
.