diff --git a/README.md b/README.md index 7f2bd43..f0ab5e1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,14 @@ # Krita Photobash Plugin An advanced Krita Plugin, laser-focused on improving productivity for photo-bashing and references! -Want to see this in action? Check out the [video](https://youtu.be/QX9jwhfpB_8)! Tested in Krita 4.4.8, 5.0 and 5.1. +Want to see this in action? Check out the [video](https://youtu.be/QX9jwhfpB_8)! Tested in Krita 4.4.8, 5.0 and 5.1.,5.2.1 + +## Changes +- Supports search by extra caption file (must be a text file with the same name as the image) +- Supports adding image with transparency layer +- Supports adding image grouped with an erase blending layer +- Supports reloading images in directory without needing to reset to a different directory +- Supports sorting by date (in descending order) ## Installation @@ -20,7 +27,9 @@ After setting the references folder, you now have a list of 9 images in the dock - Clicking on the "next" and "previous" buttons on the bottom row of the docker; - Scrolling the slider next to the pages indicator; - Mouse Wheel Up and Down; -- Alt + Drag Left or Right, in case you're using a stylus. +- Alt + Drag Left or Right, in case you're using a stylus. +- Clicking with Middle Mouse Button on an image adds the image with a transparency mask +- Ctrl + Left Click creates a group with the image + a layer with blending mode set to erase If the images in the folders are of large size, there may be some slowdown when scrolling quickly. However, the plugin is caching the previews, and stores up to 90 images, so you can scroll through them back more easily later. @@ -40,5 +49,7 @@ You can also have some extra features by right-clicking on an image. This will o - **Pin to Beginning / Unpin**: You can add "favourites" to an image, by pinning them to the beginning. This is useful if you have a select few images that you like to re-use, but are on different pages. This way you can have an easy way to access them, which will persist across restarts. It will only forget the favourite images if you decide to change the references folder. You can also unpin the images to send them to their original placement. A favourite will have a triangle in the top-left corner. - **Open as New Document**: Opens the image as a new document, but keep in mind that this is the original image. If you save it, it will override the one you have on your references folder. - **Place as Reference**: You can add an image as reference, and place it wherever you want! If you want to remove a reference, you need to press the "Pushpin Icon" on your toolbox, and remove it using that tool. +- **Add with Transparency**: Add the image with a transparency mask. White is to keep the pixels and Black is to erase. The added mask is white filled but the default behavior is to remove pixels if moving the transparency layer. +- **Group with Erase Layer**: Adds an image in a group with an erase blending layer. Easier to move compared to **Add with Transparency** butcan't add any additional images to the group without having the erase layer affect all images in the group. -#### Hope you enjoy this plugin, and feel free to post your artworks over on [Krita Artists](https://krita-artists.org/)! \ No newline at end of file +#### Hope you enjoy this plugin, and feel free to post your artworks over on [Krita Artists](https://krita-artists.org/)! diff --git a/photobash_images/photobash_images_docker.py b/photobash_images/photobash_images_docker.py index 7d2dc48..51058cd 100644 --- a/photobash_images/photobash_images_docker.py +++ b/photobash_images/photobash_images_docker.py @@ -13,10 +13,12 @@ # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License -# along with this program. If not, see . - +# along with this +# program. If not, see . from krita import * +from enum import Enum +from operator import itemgetter import copy import math from PyQt5 import QtWidgets, QtCore, uic @@ -26,7 +28,13 @@ ) import os.path +class TransparencyType(Enum): + NONE = 0 + LAYER = 1 + BLEND = 2 + class PhotobashDocker(DockWidget): + def __init__(self): super().__init__() @@ -50,11 +58,16 @@ def setupVariables(self): self.imagesButtons = [] self.foundImages = [] self.favouriteImages = [] + # maps path to image self.cachedImages = {} + self.cachedSearchKeywords = {} + self.cachedDatePaths = [] + self.order = [] # store order of push self.cachedPathImages = [] self.maxCachedImages = 90 + self.maxCachedSearchKeyword = 2000 self.maxNumPages = 9999 self.currPage = 0 @@ -93,6 +106,7 @@ def setupInterface(self): self.layout.imagesButtons8, ] + # Adjust Layouts self.layout.imageWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored) self.layout.middleWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) @@ -103,10 +117,30 @@ def setupInterface(self): # setup connections for bottom elements self.layout.previousButton.clicked.connect(lambda: self.updateCurrentPage(-1)) self.layout.nextButton.clicked.connect(lambda: self.updateCurrentPage(1)) + self.layout.refresh.clicked.connect(self.refresh_cache) + self.layout.dateSort.stateChanged.connect(self.sortByDate) self.layout.scaleSlider.valueChanged.connect(self.updateScale) self.layout.paginationSlider.setMinimum(0) self.layout.paginationSlider.valueChanged.connect(self.updatePage) self.layout.fitCanvasCheckBox.stateChanged.connect(self.changedFitCanvas) + self.sortImagesByDate = False + + def refresh_cache(self): + self.favouriteImages = [] + self.foundImages = [] + self.cachedSearchKeywords = {} + self.getImagesFromDirectory() + + def sortByDate(self,state): + if state == Qt.Checked: + self.sortImagesByDate = True + self.order = copy.deepcopy(self.foundImages) + else: + self.sortImagesByDate = False + self.foundImages = copy.deepcopy(self.order) + self.reorganizeImages() + self.updateImages() + def setupModules(self): # Display Single @@ -129,6 +163,10 @@ def setupModules(self): imageButton.SIGNAL_UN_FAVOURITE.connect(self.unpinFromFavourites) imageButton.SIGNAL_OPEN_NEW.connect(self.openNewDocument) imageButton.SIGNAL_REFERENCE.connect(self.placeReference) + imageButton.SIGNAL_ADD_WITH_TRANS_LAYER.connect(self.add_with_layer) + imageButton.SIGNAL_ADD_WITH_ERASE_GROUP.connect(self.add_with_blend) + imageButton.SIGNAL_MMD.connect(self.add_image_with_layer) + imageButton.SIGNAL_CTRL_LEFT.connect(self.add_image_with_group) self.imagesButtons.append(imageButton) def setStyle(self): @@ -147,6 +185,7 @@ def initialize(self): self.layout.scaleSliderLabel.setText(f"Image Scale : 100%") self.updateImages() + self.getImagesFromDirectory() def reorganizeImages(self): # organize images, taking into account favourites @@ -156,8 +195,22 @@ def reorganizeImages(self): if image in self.foundImages: self.foundImages.remove(image) favouriteFoundImages.append(image) - - self.foundImages = favouriteFoundImages + self.foundImages + if not self.sortImagesByDate: + self.foundImages = favouriteFoundImages + self.foundImages + else: + cachedDates = self.cachedDatePaths[:] + for i in self.cachedDatePaths: + if i['value'] not in self.foundImages: + cachedDates.remove(i) + favouriteDateImages = [] + for favourite in self.favouriteImages: + for image in self.cachedDatePaths: + if image['value'] == favourite: + cachedDates.remove(image) + favouriteDateImages.append(image) + break + sorted_dates = sorted(favouriteDateImages,key=itemgetter('date'),reverse=True) + sorted(cachedDates,key=itemgetter('date'),reverse=True) + self.foundImages = [image['value'] for image in sorted_dates] def textFilterChanged(self): stringsInText = self.layout.filterTextEdit.text().lower().split(" ") @@ -173,8 +226,13 @@ def textFilterChanged(self): # exclude path outside from search if word in path.replace(self.directoryPath, "").lower() and not path in newImages and word != "" and word != " ": newImages.append(path) + elif path in self.cachedSearchKeywords: + searchString = ",".join(self.cachedSearchKeywords[path]).lower() + if word in searchString and not path in newImages and word != "" and word != " ": + newImages.append(path) self.foundImages = newImages + self.order = copy.deepcopy(self.foundImages) self.reorganizeImages() self.updateImages() @@ -190,19 +248,32 @@ def getImagesFromDirectory(self): it = QDirIterator(self.directoryPath, QDirIterator.Subdirectories) - + cache_date_paths = [] while(it.hasNext()): if (".webp" in it.filePath() or ".png" in it.filePath() or ".jpg" in it.filePath() or ".jpeg" in it.filePath()) and \ (not ".webp~" in it.filePath() and not ".png~" in it.filePath() and not ".jpg~" in it.filePath() and not ".jpeg~" in it.filePath()): + self.cacheSearchTerms(it.filePath()) newImages.append(it.filePath()) - + cache_date_paths.append({ + 'date':os.path.getmtime(it.filePath()), + 'value':it.filePath() + }) it.next() - + self.cachedDatePaths = sorted(cache_date_paths,key=itemgetter('date'),reverse=True) self.foundImages = copy.deepcopy(newImages) self.allImages = copy.deepcopy(newImages) self.reorganizeImages() self.updateImages() + def cacheSearchTerms(self,key): + baseName = os.path.basename(key) + fileName = os.path.splitext(baseName)[0] + keywordFile = self.directoryPath +"/"+ fileName +'.txt' + if os.path.exists(keywordFile): + with open(keywordFile,'r') as searchFile: + lines = searchFile.readlines(self.maxCachedSearchKeyword) + self.cachedSearchKeywords[key] = lines + def updateCurrentPage(self, increment): if (self.currPage == 0 and increment == -1) or \ ((self.currPage + 1) * len(self.imagesButtons) > len(self.foundImages) and increment == 1) or \ @@ -267,12 +338,19 @@ def getImage(self, path): if len(self.cachedImages) > self.maxCachedImages: removedPath = self.cachedPathImages.pop() self.cachedImages.pop(removedPath) - + if removedPath in self.cachedSearchKeywords: + self.cachedSearchKeywords.pop(removedPath) + for i in self.cachedDatePaths[:]: + if i['value'] == removedPath: + self.cachedDatePaths.remove(i) + break self.cachedPathImages = [path] + self.cachedPathImages - self.cachedImages[path] = QImage(path).scaled(200, 200, Qt.KeepAspectRatio, Qt.FastTransformation) - + self.cachedImages[path] = QImage(path).scaled(200, 200, Qt.KeepAspectRatio, Qt.SmoothTransformation) return self.cachedImages[path] - + + def splitPathName(self,path): + baseName = os.path.basename(path) + return baseName # makes sure the first 9 found images exist def checkValidImages(self): found = 0 @@ -323,7 +401,21 @@ def updateImages(self): self.layout.paginationSlider.setRange(0, maxNumPage - 1) self.layout.paginationSlider.setSliderPosition(self.currPage) - def addImageLayer(self, photoPath): + + def add_image_with_layer(self,position): + if position < len(self.foundImages) - len(self.imagesButtons) * self.currPage: + self.addImageLayer(self.foundImages[position + len(self.imagesButtons) * self.currPage],TransparencyType.LAYER) + + def add_image_with_group(self,position): + if position < len(self.foundImages) - len(self.imagesButtons) * self.currPage: + self.addImageLayer(self.foundImages[position + len(self.imagesButtons) * self.currPage],TransparencyType.BLEND) + def add_with_layer(self,photoPath): + self.addImageLayer(photoPath,TransparencyType.LAYER) + + def add_with_blend(self,photoPath): + self.addImageLayer(photoPath,TransparencyType.BLEND) + + def addImageLayer(self, photoPath,transparency_type): # file no longer exists, remove from all structures if not self.checkPath(photoPath): self.updateImages() @@ -356,11 +448,55 @@ def addImageLayer(self, photoPath): mimedata.setUrls([url]) mimedata.setImageData(image) - # Set image in clipboard - QApplication.clipboard().setImage(image) - - # Place Image and Refresh Canvas - Krita.instance().action('edit_paste').trigger() + # get doc data + root = doc.rootNode() + active_node = doc.activeNode() + + # get base name + file_name = os.path.basename(photoPath) + + # create layer with base name + new_layer = doc.createNode(file_name,'paintlayer') + + # copy bytes from qImage into the image layer + ptr = image.bits() + ptr.setsize(image.byteCount()) + new_layer.setPixelData(QByteArray(ptr.asstring()),0,0,image.width(),image.height()) + + # center the layer + center_x = int((doc.width() - image.width())/2) + center_y = int((doc.height() - image.height())/2) + new_layer.move(center_x,center_y) + if transparency_type == TransparencyType.NONE or transparency_type == TransparencyType.LAYER: + root.addChildNode(new_layer,None) + if transparency_type == TransparencyType.LAYER: + ## create a layer + transparency_layer = doc.createTransparencyMask('transparencymask') + + ## fill with white + trans_image = image.scaled(doc.width(),doc.height(), Qt.IgnoreAspectRatio, Qt.FastTransformation) + trans_image.fill(QColor('white')) + + ## copy bytes over + trans_pointer = trans_image.bits() + trans_pointer.setsize(trans_image.byteCount()) + transparency_layer.setPixelData(QByteArray(trans_pointer.asstring()),0,0,doc.width(),doc.height()) + + new_layer.addChildNode(transparency_layer,None) + + ## move layer so centered image is not cropped. + transparency_layer.move(0,0) + + if transparency_type == transparency_type.BLEND: + new_group = doc.createGroupLayer(file_name) + root.addChildNode(new_group,None) + erase = doc.createNode('erase','paintLayer') + erase.setBlendingMode('erase') + new_group.addChildNode(new_layer,None) + new_group.addChildNode(erase,None) + + + #Refresh Canvas Krita.instance().activeDocument().refreshProjection() def checkPath(self, path): @@ -371,7 +507,12 @@ def checkPath(self, path): self.allImages.remove(path) if path in self.favouriteImages: self.favouriteImages.remove(path) - + if path in self.cachedSearchKeywords: + self.cachedSearchKeywords.remove(path) + for i in self.cachedDatePaths[:]: + if i['value'] == path: + self.cachedDatePaths.remove(i) + break dlg = QMessageBox(self) dlg.setWindowTitle("Missing Image!") dlg.setText("This image you tried to open was not found. Removing from the list.") @@ -405,7 +546,8 @@ def placeReference(self, path): Krita.instance().action('paste_as_reference').trigger() def openPreview(self, path): - self.imageWidget.setImage(path, self.getImage(path)) + image = QImage(path) + self.imageWidget.setImage(path,image) self.layout.imageWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.layout.middleWidget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored) @@ -447,7 +589,7 @@ def canvasChanged(self, canvas): def buttonClick(self, position): if position < len(self.foundImages) - len(self.imagesButtons) * self.currPage: - self.addImageLayer(self.foundImages[position + len(self.imagesButtons) * self.currPage]) + self.addImageLayer(self.foundImages[position + len(self.imagesButtons) * self.currPage],TransparencyType.NONE) def changePath(self): fileDialog = QFileDialog(QWidget(self)) @@ -460,12 +602,16 @@ def changePath(self): title = "Change Directory for Images" dialogOptions = QFileDialog.ShowDirsOnly | QFileDialog.DontUseNativeDialog - self.directoryPath = fileDialog.getExistingDirectory(self.mainWidget, title, path, dialogOptions) + new_path = fileDialog.getExistingDirectory(self.mainWidget, title, path, dialogOptions) + if self.directoryPath != "" and new_path == "": + return + self.directoryPath = new_path Application.writeSetting(self.applicationName, self.referencesSetting, self.directoryPath) self.favouriteImages = [] self.foundImages = [] - + self.cachedSearchKeywords = {} + self.cachedDatePaths = [] Application.writeSetting(self.applicationName, self.foundFavouritesSetting, "") if self.directoryPath == "": diff --git a/photobash_images/photobash_images_docker.ui b/photobash_images/photobash_images_docker.ui index f61b09c..5c6e21c 100644 --- a/photobash_images/photobash_images_docker.ui +++ b/photobash_images/photobash_images_docker.ui @@ -302,6 +302,24 @@ 0 + + + + + + Sort by Date + + + + + + + Refresh + + + + + diff --git a/photobash_images/photobash_images_modulo.py b/photobash_images/photobash_images_modulo.py index 058272e..1083f01 100644 --- a/photobash_images/photobash_images_modulo.py +++ b/photobash_images/photobash_images_modulo.py @@ -115,11 +115,11 @@ def customMouseMoveEvent(self, event): # only scale to document if it exists if self.fitCanvasChecked and not doc is None: - fullImage = QImage(self.path).scaled(doc.width() * scale, doc.height() * scale, Qt.KeepAspectRatio, Qt.SmoothTransformation) + fullImage = QImage(self.path).scaled(int(doc.width() * scale), int(doc.height() * scale), Qt.KeepAspectRatio, Qt.SmoothTransformation) else: fullImage = QImage(self.path) # scale image, now knowing the bounds - fullImage = fullImage.scaled(fullImage.width() * scale, fullImage.height() * scale, Qt.KeepAspectRatio, Qt.SmoothTransformation) + fullImage = fullImage.scaled(int(fullImage.width() * scale), int(fullImage.height() * scale), Qt.KeepAspectRatio, Qt.SmoothTransformation) fullPixmap = QPixmap(50, 50).fromImage(fullImage) mimedata.setImageData(fullPixmap) @@ -131,7 +131,7 @@ def customMouseMoveEvent(self, event): drag = QDrag(self) drag.setMimeData(mimedata) drag.setPixmap(self.pixmap) - drag.setHotSpot(QPoint(self.qimage.width() / 2, self.qimage.height() / 2)) + drag.setHotSpot(QPoint(int(self.qimage.width() / 2), int(self.qimage.height() / 2))) drag.exec_(Qt.CopyAction) class Photobash_Display(QWidget): @@ -184,6 +184,11 @@ class Photobash_Button(QWidget): SIGNAL_OPEN_NEW = QtCore.pyqtSignal(str) SIGNAL_REFERENCE = QtCore.pyqtSignal(str) SIGNAL_DRAG = QtCore.pyqtSignal(int) + SIGNAL_ADD_WITH_TRANS_LAYER = QtCore.pyqtSignal(str) + SIGNAL_ADD_WITH_ERASE_GROUP = QtCore.pyqtSignal(str) + SIGNAL_MMD = QtCore.pyqtSignal(int) + SIGNAL_CTRL_LEFT = QtCore.pyqtSignal(int) + PREVIOUS_DRAG_X = None fitCanvasChecked = False scale = 100 @@ -223,6 +228,10 @@ def leaveEvent(self, event): def mousePressEvent(self, event): if event.modifiers() == QtCore.Qt.NoModifier and event.buttons() == QtCore.Qt.LeftButton: self.SIGNAL_LMB.emit(self.number) + if event.modifiers() == QtCore.Qt.ControlModifier and event.buttons() == QtCore.Qt.LeftButton: + self.SIGNAL_CTRL_LEFT.emit(self.number) + if event.modifiers() == QtCore.Qt.NoModifier and event.buttons() == QtCore.Qt.MiddleButton: + self.SIGNAL_MMD.emit(self.number) if event.modifiers() == QtCore.Qt.AltModifier: self.PREVIOUS_DRAG_X = event.x() @@ -249,7 +258,8 @@ def contextMenuEvent(self, event): cmenuFavourite = cmenu.addAction(favouriteString) cmenuOpenNew = cmenu.addAction("Open as New Document") cmenuReference = cmenu.addAction("Place as Reference") - + cmenuTransparency = cmenu.addAction("Add with Transparency") + cmenuEraseGroup = cmenu.addAction("Group with Erase Layer") background = qApp.palette().color(QPalette.Window).name().split("#")[1] cmenuStyleSheet = f"""QMenu {{ background-color: #AA{background}; border: 1px solid #{background}; }}""" cmenu.setStyleSheet(cmenuStyleSheet) @@ -266,6 +276,10 @@ def contextMenuEvent(self, event): self.SIGNAL_OPEN_NEW.emit(self.path) if action == cmenuReference: self.SIGNAL_REFERENCE.emit(self.path) + if action == cmenuTransparency: + self.SIGNAL_ADD_WITH_TRANS_LAYER.emit(self.path) + if action == cmenuEraseGroup: + self.SIGNAL_ADD_WITH_ERASE_GROUP.emit(self.path) def setImage(self, path, image): self.path = path