Compare commits

...

30 Commits

Author SHA1 Message Date
Travis Abendshien
57e27bb51f Revert "Fix create library + type checks"
This reverts commit 6357fea8db.
2024-05-20 17:44:52 -07:00
Travis Abendshien
66ec0913b6 Revert "Add duplicate entry handling (Fix #179)"
This reverts commit 491ebb6714.
2024-05-20 17:43:25 -07:00
Travis Abendshien
6357fea8db Fix create library + type checks 2024-05-20 17:36:22 -07:00
Travis Abendshien
491ebb6714 Add duplicate entry handling (Fix #179)
- Running "Fix Unlinked Entries" will no longer result in duplicate entries if the directory was refreshed after the original entries became unlinked.
- A "Duplicate Entries" section is added to the "Fix Unlinked Entries" modal to help repair existing affected libraries.
2024-05-20 17:14:30 -07:00
Travis Abendshien
385b4117db Fix incorrect pillow-heif import 2024-05-18 19:03:57 -07:00
Travis Abendshien
be3992f655 Add HEIC/HEIF image support
- Add support for HEIC/HEIF image thumbnails and previews
- Replace dependency "pillow_avif_plugin" with "pi-heif"
- Remove unused dependencies in ts_cli.py
2024-05-18 18:58:01 -07:00
Travis Abendshien
18becd62a3 Add RAW image support (Resolve #193)
- Add thumbnail and preview support for RAW images ["raw", "dng", "rw2", "nef", "arw", "crw", "cr3"]
- Optimize the preview panel's dimension calculations (still need to move this elsewhere)
- Refactored use of "Path" in thumb_renderer.py
2024-05-18 18:49:35 -07:00
Travis Abendshien
699ecd367c Adaptive resampling method for images (Fix #174)
When loading an image for thumbnails and previews, the resampling method is now determined by the size of the original image. Now low resolution images use "nearest neighbor" sampling while higher resolution images continue to use "bilinear" sampling.
2024-05-18 17:57:28 -07:00
Travis Abendshien
9d7609a8e5 Load palletized images as RGBA (Fix #175) 2024-05-18 17:32:54 -07:00
Theasacraft
e94c4871d7 Refactor Thumbrenderer (#168)
* Merge Render methods

* Cleanup comments

* Removed old render methods and replaced with new one

* Fix Formatting

- Change all instances of "os.path.normpath" to pathlib's "Path"
- Remove unused import
- Modify log formatting
- Change "self.tr" to "self.thumb_renderer" to avoid masking internal method
- Restore DecompressionBombError handling from main
- Misc. formatting

* Fix MyPy no-redef

---------

Co-authored-by: Travis Abendshien <lvnvtravis@gmail.com>
2024-05-18 16:56:45 -07:00
Travis Abendshien
02bf15e080 Merge pull request #142 from Hidorikun/test-support-2
Add pytest support
2024-05-17 21:13:41 -07:00
Travis Abendshien
5f60ec1702 Add missing imports to ts_core.py 2024-05-17 21:12:02 -07:00
Travis Abendshien
cdf2581f84 Merge pull request #192 from yedpodtrzitko/yed/better-mypy-pr
use reviewdog for mypy job
2024-05-17 21:02:02 -07:00
yedpodtrzitko
af8b4e3872 use reviewdog for mypy job 2024-05-18 11:21:40 +08:00
Travis Abendshien
ac9dd5879e Merge pull request #189 from michaelmegrath/main
fix: Clear Edit Button on container update (#115)
2024-05-17 14:14:59 -07:00
Michael Megrath
badcd72bea fix: Clear Edit Button on container update (#115) 2024-05-16 22:09:41 -07:00
Vele George
8733c8d301 Update constants.py 2024-05-16 10:37:34 +03:00
Vele George
4726f1fc63 Merge branch 'main' into test-support-2 2024-05-16 10:37:27 +03:00
Travis Abendshien
1461f2ee70 Merge pull request #186 from yedpodtrzitko/main
fix: update recent libs when creating new one
2024-05-15 22:39:42 -07:00
yedpodtrzitko
1bfc24b70f fix: update recent libs when creating new one 2024-05-16 13:28:29 +08:00
Jiri
c09f50c568 ci: add mypy check (#161)
* ci: add mypy check

* fix remaining mypy issues

* ignore whole methods
2024-05-15 22:25:53 -07:00
Travis Abendshien
66aecf2030 Merge pull request #180 from yedpodtrzitko/yed/fix-sidebar-size
fix sidebar expanding
2024-05-15 16:38:57 -07:00
yedpodtrzitko
dc188264f9 fix sidebar expanding 2024-05-16 07:25:21 +08:00
Travis Abendshien
6e56f13eda Bump version to v9.2.1 2024-05-15 15:30:33 -07:00
Vele George
c9ea25b940 Merge branch 'main' into test-support-2 2024-05-15 10:23:36 +03:00
Travis Abendshien
e814d09c60 Add macOS Gatekeeper note to README 2024-05-15 00:15:44 -07:00
Vele George
4b1119ecba Merge branch 'main' into test-support-2 2024-05-14 11:38:24 +03:00
Vele George
ad850cba94 Merge branch 'main' into test-support-2 2024-05-11 21:30:15 +03:00
Hidorikun
b6848bb81f Ruff format 2024-05-08 17:32:15 +03:00
Hidorikun
fb7c73d96b Add pytest support 2024-05-06 12:06:30 +03:00
29 changed files with 626 additions and 704 deletions

35
.github/workflows/mypy.yaml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: MyPy
on: [ push, pull_request ]
jobs:
mypy:
name: Run MyPy
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- uses: reviewdog/action-setup@v1
with:
reviewdog_version: latest
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# pyside 6.6.3 has some issue in their .pyi files
pip install PySide6==6.6.2
pip install -r requirements.txt
pip install mypy==1.10.0
mkdir tagstudio/.mypy_cache
- uses: tsuyoshicho/action-mypy@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-check
fail_on_error: true
workdir: tagstudio
level: error
mypy_flags: --config-file ../pyproject.toml

View File

@@ -56,7 +56,8 @@ TagStudio is a photo & file organization application with an underlying system t
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
Once downloaded, launch the corresponding TagStudio executable to start the program. Once open, go to **"File -> Create/Open Library"** to create your first library from a directory!
> [!NOTE]
> On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.
#### Optional Arguments
@@ -182,7 +183,12 @@ _Skip these steps if launching from the .sh script on Linux/macOS._
- Linux/macOS: `source .venv/bin/activate`
3. Install the required packages:
`pip install -r requirements.txt`
- required to run the app: `pip install -r requirements.txt`
- required to develop: `pip install -r requirements-dev.txt`
To run all the tests use `python -m pytest tests/` from the `tagstudio` folder.
_Learn more about setting up a virtual environment [here](https://docs.python.org/3/tutorial/venv.html)._

View File

@@ -1,2 +1,8 @@
[tool.ruff]
exclude = ["main_window.py", "home_ui.py", "resources.py", "resources_rc.py"]
[tool.mypy]
strict_optional = false
disable_error_code = ["union-attr", "annotation-unchecked", "import-untyped"]
explicit_package_bases = true
warn_unused_ignores = true

View File

@@ -1,3 +1,5 @@
ruff==0.4.2
pre-commit==3.7.0
pytest==8.2.0
Pyinstaller==6.6.0
mypy==1.10.0

View File

@@ -1,9 +1,10 @@
humanfriendly==10.0
opencv_python>=4.8.0.74,<=4.9.0.80
Pillow==10.3.0
pillow_avif_plugin>=1.3.1,<=1.4.3
PySide6>=6.5.1.1,<=6.6.3.1
PySide6_Addons>=6.5.1.1,<=6.6.3.1
PySide6_Essentials>=6.5.1.1,<=6.6.3.1
typing_extensions>=3.10.0.0,<=4.11.0
ujson>=5.8.0,<=5.9.0
rawpy==0.21.0
pillow-heif==0.16.0

View File

@@ -1,3 +1,4 @@
# type: ignore
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
@@ -6,14 +7,17 @@
import datetime
import math
from multiprocessing import Value
# from multiprocessing import Value
import os
import subprocess
# import subprocess
import sys
import time
from PIL import Image, ImageOps, ImageChops, UnidentifiedImageError
from PIL import Image, ImageChops, UnidentifiedImageError
from PIL.Image import DecompressionBombError
import pillow_avif
# import pillow_avif
from pathlib import Path
import traceback
import cv2

View File

@@ -0,0 +1,137 @@
VERSION: str = "9.2.1" # Major.Minor.Patch
VERSION_BRANCH: str = "Pre-Release" # 'Alpha', 'Beta', or '' for Full Release
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
BACKUP_FOLDER_NAME: str = "backups"
COLLAGE_FOLDER_NAME: str = "collages"
LIBRARY_FILENAME: str = "ts_library.json"
# TODO: Turn this whitelist into a user-configurable blacklist.
IMAGE_TYPES: list[str] = [
"png",
"jpg",
"jpeg",
"jpg_large",
"jpeg_large",
"jfif",
"gif",
"tif",
"tiff",
"heic",
"heif",
"webp",
"bmp",
"svg",
"avif",
"apng",
"jp2",
"j2k",
"jpg2",
]
RAW_IMAGE_TYPES: list[str] = ["raw", "dng", "rw2", "nef", "arw", "crw", "cr3"]
VIDEO_TYPES: list[str] = [
"mp4",
"webm",
"mov",
"hevc",
"mkv",
"avi",
"wmv",
"flv",
"gifv",
"m4p",
"m4v",
"3gp",
]
AUDIO_TYPES: list[str] = [
"mp3",
"mp4",
"mpeg4",
"m4a",
"aac",
"wav",
"flac",
"alac",
"wma",
"ogg",
"aiff",
]
DOC_TYPES: list[str] = ["txt", "rtf", "md", "doc", "docx", "pdf", "tex", "odt", "pages"]
PLAINTEXT_TYPES: list[str] = [
"txt",
"md",
"css",
"html",
"xml",
"json",
"js",
"ts",
"ini",
"htm",
"csv",
"php",
"sh",
"bat",
]
SPREADSHEET_TYPES: list[str] = ["csv", "xls", "xlsx", "numbers", "ods"]
PRESENTATION_TYPES: list[str] = ["ppt", "pptx", "key", "odp"]
ARCHIVE_TYPES: list[str] = ["zip", "rar", "tar", "tar.gz", "tgz", "7z"]
PROGRAM_TYPES: list[str] = ["exe", "app"]
SHORTCUT_TYPES: list[str] = ["lnk", "desktop", "url"]
ALL_FILE_TYPES: list[str] = (
IMAGE_TYPES
+ VIDEO_TYPES
+ AUDIO_TYPES
+ DOC_TYPES
+ SPREADSHEET_TYPES
+ PRESENTATION_TYPES
+ ARCHIVE_TYPES
+ PROGRAM_TYPES
+ SHORTCUT_TYPES
)
BOX_FIELDS = ["tag_box", "text_box"]
TEXT_FIELDS = ["text_line", "text_box"]
DATE_FIELDS = ["datetime"]
TAG_COLORS = [
"",
"black",
"dark gray",
"gray",
"light gray",
"white",
"light pink",
"pink",
"red",
"red orange",
"orange",
"yellow orange",
"yellow",
"lime",
"light green",
"mint",
"green",
"teal",
"cyan",
"light blue",
"blue",
"blue violet",
"violet",
"purple",
"lavender",
"berry",
"magenta",
"salmon",
"auburn",
"dark brown",
"brown",
"light brown",
"blonde",
"peach",
"warm gray",
"cool gray",
"olive",
]

View File

@@ -19,7 +19,7 @@ class FieldTemplate:
def to_compressed_obj(self) -> dict:
"""An alternative to __dict__ that only includes fields containing non-default data."""
obj = {}
obj: dict = {}
# All Field fields (haha) are mandatory, so no value checks are done.
obj["id"] = self.id
obj["name"] = self.name

View File

@@ -8,6 +8,7 @@ class JsonLibary(TypedDict("", {"ts-version": str})):
fields: list # TODO
macros: "list[JsonMacro]"
entries: "list[JsonEntry]"
ignored_extensions: list[str]
class JsonBase(TypedDict):

View File

@@ -6,20 +6,31 @@
import datetime
import glob
import json
import logging
import os
import sys
import time
import traceback
import typing
import xml.etree.ElementTree as ET
from enum import Enum
from pathlib import Path
from typing import cast, Generator
from typing_extensions import Self
import ujson
from src.core.json_typing import JsonCollation, JsonEntry, JsonLibary, JsonTag
from src.core import ts_core
from src.core.utils.str import strip_punctuation
from src.core.utils.web import strip_web_protocol
from src.core.constants import (
BACKUP_FOLDER_NAME,
COLLAGE_FOLDER_NAME,
TEXT_FIELDS,
TS_FOLDER_NAME,
VERSION,
)
TYPE = ["file", "meta", "alt", "mask"]
@@ -42,7 +53,7 @@ class Entry:
self.id = int(id)
self.filename = filename
self.path = path
self.fields = fields
self.fields: list[dict] = fields
self.type = None
# Optional Fields ======================================================
@@ -75,6 +86,7 @@ class Entry:
return self.__str__()
def __eq__(self, __value: object) -> bool:
__value = cast(Self, object)
if os.name == "nt":
return (
int(self.id) == int(__value.id)
@@ -129,18 +141,16 @@ class Entry:
)
t.remove(tag_id)
elif field_index < 0:
t: list[int] = library.get_field_attr(f, "content")
t = library.get_field_attr(f, "content")
while tag_id in t:
t.remove(tag_id)
def add_tag(
self, library: "Library", tag_id: int, field_id: int, field_index: int = None
self, library: "Library", tag_id: int, field_id: int, field_index: int = -1
):
# field_index: int = -1
# if self.fields:
# if field_index != -1:
# logging.info(f'[LIBRARY] ADD TAG to E:{self.id}, F-DI:{field_id}, F-INDEX:{field_index}')
field_index = -1 if field_index is None else field_index
for i, f in enumerate(self.fields):
if library.get_field_attr(f, "id") == field_id:
field_index = i
@@ -183,7 +193,7 @@ class Tag:
self.shorthand = shorthand
self.aliases = aliases
# Ensures no duplicates while retaining order.
self.subtag_ids = []
self.subtag_ids: list[int] = []
for s in subtags_ids:
if int(s) not in self.subtag_ids:
self.subtag_ids.append(int(s))
@@ -275,17 +285,19 @@ class Collation:
def __repr__(self) -> str:
return self.__str__()
@typing.no_type_check
def __eq__(self, __value: object) -> bool:
__value = cast(Self, __value)
if os.name == "nt":
return (
int(self.id) == int(__value.id_)
int(self.id) == int(__value.id)
and self.filename.lower() == __value.filename.lower()
and self.path.lower() == __value.path.lower()
and self.fields == __value.fields
)
else:
return (
int(self.id) == int(__value.id_)
int(self.id) == int(__value.id)
and self.filename == __value.filename
and self.path == __value.path
and self.fields == __value.fields
@@ -342,7 +354,7 @@ class Library:
self.files_not_in_library: list[str] = []
self.missing_files: list[str] = []
self.fixed_files: list[str] = [] # TODO: Get rid of this.
self.missing_matches = {}
self.missing_matches: dict = {}
# Duplicate Files
# Defined by files that are exact or similar copies to others. Generated by DupeGuru.
# (Filepath, Matched Filepath, Match Percentage)
@@ -393,7 +405,7 @@ class Library:
# Tag(id=1, name='Favorite', shorthand='', aliases=['Favorited, Favorites, Likes, Liked, Loved'], subtags_ids=[], color='yellow'),
# ]
self.default_fields = [
self.default_fields: list[dict] = [
{"id": 0, "name": "Title", "type": "text_line"},
{"id": 1, "name": "Author", "type": "text_line"},
{"id": 2, "name": "Artist", "type": "text_line"},
@@ -438,8 +450,8 @@ class Library:
path = os.path.normpath(path).rstrip("\\")
# If '.TagStudio' is included in the path, trim the path up to it.
if ts_core.TS_FOLDER_NAME in path:
path = path.split(ts_core.TS_FOLDER_NAME)[0]
if TS_FOLDER_NAME in path:
path = path.split(TS_FOLDER_NAME)[0]
try:
self.clear_internal_vars()
@@ -456,12 +468,12 @@ class Library:
def verify_ts_folders(self) -> None:
"""Verifies/creates folders required by TagStudio."""
full_ts_path = os.path.normpath(f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}")
full_ts_path = os.path.normpath(f"{self.library_dir}/{TS_FOLDER_NAME}")
full_backup_path = os.path.normpath(
f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.BACKUP_FOLDER_NAME}"
f"{self.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}"
)
full_collage_path = os.path.normpath(
f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.COLLAGE_FOLDER_NAME}"
f"{self.library_dir}/{TS_FOLDER_NAME}/{COLLAGE_FOLDER_NAME}"
)
if not os.path.isdir(full_ts_path):
@@ -499,21 +511,17 @@ class Library:
path = os.path.normpath(path).rstrip("\\")
# If '.TagStudio' is included in the path, trim the path up to it.
if ts_core.TS_FOLDER_NAME in path:
path = path.split(ts_core.TS_FOLDER_NAME)[0]
if TS_FOLDER_NAME in path:
path = path.split(TS_FOLDER_NAME)[0]
if os.path.exists(
os.path.normpath(f"{path}/{ts_core.TS_FOLDER_NAME}/ts_library.json")
):
if os.path.exists(os.path.normpath(f"{path}/{TS_FOLDER_NAME}/ts_library.json")):
try:
with open(
os.path.normpath(
f"{path}/{ts_core.TS_FOLDER_NAME}/ts_library.json"
),
os.path.normpath(f"{path}/{TS_FOLDER_NAME}/ts_library.json"),
"r",
encoding="utf-8",
) as f:
json_dump: JsonLibary = ujson.load(f)
) as file:
json_dump: JsonLibary = ujson.load(file)
self.library_dir = str(path)
self.verify_ts_folders()
major, minor, patch = json_dump["ts-version"].split(".")
@@ -591,7 +599,7 @@ class Library:
filename = entry.get("filename", "")
e_path = entry.get("path", "")
fields = []
fields: list = []
if "fields" in entry:
# Cast JSON str keys to ints
for f in entry["fields"]:
@@ -688,14 +696,14 @@ class Library:
self._next_collation_id = id + 1
title = collation.get("title", "")
e_ids_and_pages = collation.get("e_ids_and_pages", "")
sort_order = collation.get("sort_order", [])
cover_id = collation.get("cover_id", [])
e_ids_and_pages = collation.get("e_ids_and_pages", [])
sort_order = collation.get("sort_order", "")
cover_id = collation.get("cover_id", -1)
c = Collation(
id=id,
title=title,
e_ids_and_pages=e_ids_and_pages,
e_ids_and_pages=e_ids_and_pages, # type: ignore
sort_order=sort_order,
cover_id=cover_id,
)
@@ -718,11 +726,9 @@ class Library:
# If the Library is loaded, continue other processes.
if return_code == 1:
if not os.path.exists(
os.path.normpath(f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}")
os.path.normpath(f"{self.library_dir}/{TS_FOLDER_NAME}")
):
os.makedirs(
os.path.normpath(f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}")
)
os.makedirs(os.path.normpath(f"{self.library_dir}/{TS_FOLDER_NAME}"))
self._map_filenames_to_entry_ids()
@@ -769,7 +775,7 @@ class Library:
"""
file_to_save: JsonLibary = {
"ts-version": ts_core.VERSION,
"ts-version": VERSION,
"ignored_extensions": [],
"tags": [],
"collations": [],
@@ -807,7 +813,7 @@ class Library:
self.verify_ts_folders()
with open(
os.path.normpath(f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{filename}"),
os.path.normpath(f"{self.library_dir}/{TS_FOLDER_NAME}/{filename}"),
"w",
encoding="utf-8",
) as outfile:
@@ -836,7 +842,7 @@ class Library:
self.verify_ts_folders()
with open(
os.path.normpath(
f"{self.library_dir}/{ts_core.TS_FOLDER_NAME}/{ts_core.BACKUP_FOLDER_NAME}/{filename}"
f"{self.library_dir}/{TS_FOLDER_NAME}/{BACKUP_FOLDER_NAME}/{filename}"
),
"w",
encoding="utf-8",
@@ -861,45 +867,45 @@ class Library:
self.is_legacy_library = False
self.entries.clear()
self._next_entry_id: int = 0
self._next_entry_id = 0
# self.filtered_entries.clear()
self._entry_id_to_index_map.clear()
self._collation_id_to_index_map.clear()
self.missing_matches = {}
self.dir_file_count: int = -1
self.dir_file_count = -1
self.files_not_in_library.clear()
self.missing_files.clear()
self.fixed_files.clear()
self.filename_to_entry_id_map: dict[str, int] = {}
self.filename_to_entry_id_map = {}
self.ignored_extensions = self.default_ext_blacklist
self.tags.clear()
self._next_tag_id: int = 1000
self._tag_strings_to_id_map: dict[str, list[int]] = {}
self._tag_id_to_cluster_map: dict[int, list[int]] = {}
self._tag_id_to_index_map: dict[int, int] = {}
self._next_tag_id = 1000
self._tag_strings_to_id_map = {}
self._tag_id_to_cluster_map = {}
self._tag_id_to_index_map = {}
self._tag_entry_ref_map.clear()
def refresh_dir(self):
def refresh_dir(self) -> Generator:
"""Scans a directory for files, and adds those relative filenames to internal variables."""
# Reset file interfacing variables.
# -1 means uninitialized, aka a scan like this was never attempted before.
self.dir_file_count: int = 0
self.dir_file_count = 0
self.files_not_in_library.clear()
# Scans the directory for files, keeping track of:
# - Total file count
# - Files without library entries
# for type in ts_core.TYPES:
# for type in TYPES:
start_time = time.time()
for f in glob.glob(self.library_dir + "/**/*", recursive=True):
# p = Path(os.path.normpath(f))
if (
"$RECYCLE.BIN" not in f
and ts_core.TS_FOLDER_NAME not in f
and TS_FOLDER_NAME not in f
and "tagstudio_thumbs" not in f
and not os.path.isdir(f)
):
@@ -1210,6 +1216,7 @@ class Library:
# (int, str)
self._map_filenames_to_entry_ids()
# TODO - the type here doesnt match but I cant reproduce calling this
self.remove_missing_matches(fixed_indices)
# for i in fixed_indices:
@@ -1330,10 +1337,11 @@ class Library:
return self.collations[self._collation_id_to_index_map[int(collation_id)]]
# @deprecated('Use new Entry ID system.')
def get_entry_from_index(self, index: int) -> Entry:
def get_entry_from_index(self, index: int) -> Entry | None:
"""Returns a Library Entry object given its index in the unfiltered Entries list."""
if self.entries:
return self.entries[int(index)]
return None
# @deprecated('Use new Entry ID system.')
def get_entry_id_from_filepath(self, filename):
@@ -1368,7 +1376,7 @@ class Library:
if query:
# start_time = time.time()
query: str = query.strip().lower()
query = query.strip().lower()
query_words: list[str] = query.split(" ")
all_tag_terms: list[str] = []
only_untagged: bool = "untagged" in query or "no tags" in query
@@ -1548,7 +1556,7 @@ class Library:
else:
for entry in self.entries:
added = False
allowed_ext: bool = (
allowed_ext = (
os.path.splitext(entry.filename)[1][1:].lower()
not in self.ignored_extensions
)
@@ -1756,7 +1764,7 @@ class Library:
# if context and id_weights:
# time.sleep(3)
[final.append(idw[0]) for idw in id_weights if idw[0] not in final]
[final.append(idw[0]) for idw in id_weights if idw[0] not in final] # type: ignore
# print(f'Final IDs: \"{[self.get_tag_from_id(id).display_name(self) for id in final]}\"')
# print('')
return final
@@ -1774,7 +1782,7 @@ class Library:
return subtag_ids
def filter_field_templates(self: str, query) -> list[int]:
def filter_field_templates(self, query: str) -> list[int]:
"""Returns a list of Field Template IDs returned from a string query."""
matches: list[int] = []
@@ -2113,7 +2121,7 @@ class Library:
# entry = self.entries[entry_index]
entry = self.get_entry(entry_id)
field_type = self.get_field_obj(field_id)["type"]
if field_type in ts_core.TEXT_FIELDS:
if field_type in TEXT_FIELDS:
entry.fields.append({int(field_id): ""})
elif field_type == "tag_box":
entry.fields.append({int(field_id): []})
@@ -2127,12 +2135,12 @@ class Library:
def mirror_entry_fields(self, entry_ids: list[int]) -> None:
"""Combines and mirrors all fields across a list of given Entry IDs."""
all_fields = []
all_ids = [] # Parallel to all_fields
all_fields: list = []
all_ids: list = [] # Parallel to all_fields
# Extract and merge all fields from all given Entries.
for id in entry_ids:
if id:
entry: Entry = self.get_entry(id)
entry = self.get_entry(id)
if entry and entry.fields:
for field in entry.fields:
# First checks if their are matching tag_boxes to append to
@@ -2153,7 +2161,7 @@ class Library:
# Replace each Entry's fields with the new merged ones.
for id in entry_ids:
entry: Entry = self.get_entry(id)
entry = self.get_entry(id)
if entry:
entry.fields = all_fields
@@ -2181,7 +2189,7 @@ class Library:
# pass
# # TODO: Implement.
def get_field_attr(self, entry_field, attribute: str):
def get_field_attr(self, entry_field: dict, attribute: str):
"""Returns the value of a specified attribute inside an Entry field."""
if attribute.lower() == "id":
return list(entry_field.keys())[0]
@@ -2236,7 +2244,7 @@ class Library:
self._tag_strings_to_id_map[shorthand].append(tag.id)
for alias in tag.aliases:
alias: str = strip_punctuation(alias).lower()
alias = strip_punctuation(alias).lower()
if alias not in self._tag_strings_to_id_map:
self._tag_strings_to_id_map[alias] = []
self._tag_strings_to_id_map[alias].append(tag.id)

View File

@@ -5,7 +5,7 @@
from enum import Enum
class ColorType(Enum):
class ColorType(int, Enum):
PRIMARY = 0
TEXT = 1
BORDER = 2
@@ -278,7 +278,7 @@ _TAG_COLORS = {
}
def get_tag_color(type: ColorType, color: str):
def get_tag_color(type, color):
color = color.lower()
try:
if type == ColorType.TEXT:

View File

@@ -8,143 +8,7 @@ import json
import os
from src.core.library import Entry, Library
VERSION: str = "9.2.0" # Major.Minor.Patch
VERSION_BRANCH: str = "Alpha" # 'Alpha', 'Beta', or '' for Full Release
# The folder & file names where TagStudio keeps its data relative to a library.
TS_FOLDER_NAME: str = ".TagStudio"
BACKUP_FOLDER_NAME: str = "backups"
COLLAGE_FOLDER_NAME: str = "collages"
LIBRARY_FILENAME: str = "ts_library.json"
# TODO: Turn this whitelist into a user-configurable blacklist.
IMAGE_TYPES: list[str] = [
"png",
"jpg",
"jpeg",
"jpg_large",
"jpeg_large",
"jfif",
"gif",
"tif",
"tiff",
"heic",
"heif",
"webp",
"bmp",
"svg",
"avif",
"apng",
"jp2",
"j2k",
"jpg2",
]
VIDEO_TYPES: list[str] = [
"mp4",
"webm",
"mov",
"hevc",
"mkv",
"avi",
"wmv",
"flv",
"gifv",
"m4p",
"m4v",
"3gp",
]
AUDIO_TYPES: list[str] = [
"mp3",
"mp4",
"mpeg4",
"m4a",
"aac",
"wav",
"flac",
"alac",
"wma",
"ogg",
"aiff",
]
DOC_TYPES: list[str] = ["txt", "rtf", "md", "doc", "docx", "pdf", "tex", "odt", "pages"]
PLAINTEXT_TYPES: list[str] = [
"txt",
"md",
"css",
"html",
"xml",
"json",
"js",
"ts",
"ini",
"htm",
"csv",
"php",
"sh",
"bat",
]
SPREADSHEET_TYPES: list[str] = ["csv", "xls", "xlsx", "numbers", "ods"]
PRESENTATION_TYPES: list[str] = ["ppt", "pptx", "key", "odp"]
ARCHIVE_TYPES: list[str] = ["zip", "rar", "tar", "tar.gz", "tgz", "7z"]
PROGRAM_TYPES: list[str] = ["exe", "app"]
SHORTCUT_TYPES: list[str] = ["lnk", "desktop", "url"]
ALL_FILE_TYPES: list[str] = (
IMAGE_TYPES
+ VIDEO_TYPES
+ AUDIO_TYPES
+ DOC_TYPES
+ SPREADSHEET_TYPES
+ PRESENTATION_TYPES
+ ARCHIVE_TYPES
+ PROGRAM_TYPES
+ SHORTCUT_TYPES
)
BOX_FIELDS = ["tag_box", "text_box"]
TEXT_FIELDS = ["text_line", "text_box"]
DATE_FIELDS = ["datetime"]
TAG_COLORS = [
"",
"black",
"dark gray",
"gray",
"light gray",
"white",
"light pink",
"pink",
"red",
"red orange",
"orange",
"yellow orange",
"yellow",
"lime",
"light green",
"mint",
"green",
"teal",
"cyan",
"light blue",
"blue",
"blue violet",
"violet",
"purple",
"lavender",
"berry",
"magenta",
"salmon",
"auburn",
"dark brown",
"brown",
"light brown",
"blonde",
"peach",
"warm gray",
"cool gray",
"olive",
]
from src.core.constants import TS_FOLDER_NAME, TEXT_FIELDS
class TagStudioCore:
@@ -300,7 +164,7 @@ class TagStudioCore:
# input()
pass
def build_url(self, entry_id: int, source: str) -> str:
def build_url(self, entry_id: int, source: str):
"""Tries to rebuild a source URL given a specific filename structure."""
source = source.lower().replace("-", " ").replace("_", " ")

View File

@@ -3,9 +3,8 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from types import FunctionType
from PySide6.QtCore import Signal, QObject
from typing import Callable
class FunctionIterator(QObject):
@@ -13,7 +12,7 @@ class FunctionIterator(QObject):
value = Signal(object)
def __init__(self, function: FunctionType):
def __init__(self, function: Callable):
super().__init__()
self.iterable = function

View File

@@ -0,0 +1,50 @@
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
# Licensed under the GPL-3.0 License.
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
from PIL import Image, ImageEnhance, ImageChops
def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl):
if image.size != (adj_size, adj_size):
# Old 1 color method.
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
# bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
# bg.thumbnail((1, 1))
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
# Small gradient background. Looks decent, and is only a one-liner.
# bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR)
# Four-Corner Gradient Background.
# Not exactly a one-liner, but it's (subjectively) really cool.
tl = image.getpixel((0, 0))
tr = image.getpixel(((image.size[0] - 1), 0))
bl = image.getpixel((0, (image.size[1] - 1)))
br = image.getpixel(((image.size[0] - 1), (image.size[1] - 1)))
bg = Image.new(mode="RGB", size=(2, 2))
bg.paste(tl, (0, 0, 2, 2))
bg.paste(tr, (1, 0, 2, 2))
bg.paste(bl, (0, 1, 2, 2))
bg.paste(br, (1, 1, 2, 2))
bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC)
bg.paste(
image,
box=(
(adj_size - image.size[0]) // 2,
(adj_size - image.size[1]) // 2,
),
)
bg.putalpha(mask)
final = bg
else:
image.putalpha(mask)
final = image
hl_soft = hl.copy()
hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5))
final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3))
return final

View File

@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
from src.core.library import Library, Tag
from src.core.palette import ColorType, get_tag_color
from src.core.ts_core import TAG_COLORS
from src.core.constants import TAG_COLORS
from src.qt.widgets.panel import PanelWidget, PanelModal
from src.qt.widgets.tag import TagWidget
from src.qt.modals.tag_search import TagSearchPanel

View File

@@ -36,18 +36,18 @@ logging.basicConfig(format="%(message)s", level=logging.INFO)
def folders_to_tags(library: Library):
logging.info("Converting folders to Tags")
tree = dict(dirs={})
tree: dict = dict(dirs={})
def add_tag_to_tree(list: list[Tag]):
def add_tag_to_tree(items: list[Tag]):
branch = tree
for tag in list:
for tag in items:
if tag.name not in branch["dirs"]:
branch["dirs"][tag.name] = dict(dirs={}, tag=tag)
branch = branch["dirs"][tag.name]
def add_folders_to_tree(list: list[str]) -> Tag:
branch = tree
for folder in list:
def add_folders_to_tree(items: list[str]) -> Tag:
branch: dict = tree
for folder in items:
if folder not in branch["dirs"]:
new_tag = Tag(
-1,
@@ -97,18 +97,18 @@ def reverse_tag(library: Library, tag: Tag, list: list[Tag]) -> list[Tag]:
def generate_preview_data(library: Library):
tree = dict(dirs={}, files=[])
tree: dict = dict(dirs={}, files=[])
def add_tag_to_tree(list: list[Tag]):
branch = tree
for tag in list:
def add_tag_to_tree(items: list[Tag]):
branch: dict = tree
for tag in items:
if tag.name not in branch["dirs"]:
branch["dirs"][tag.name] = dict(dirs={}, tag=tag, files=[])
branch = branch["dirs"][tag.name]
def add_folders_to_tree(list: list[str]) -> Tag:
branch = tree
for folder in list:
def add_folders_to_tree(items: list[str]) -> dict:
branch: dict = tree
for folder in items:
if folder not in branch["dirs"]:
new_tag = Tag(-1, folder, "", [], [], "green")
branch["dirs"][folder] = dict(dirs={}, tag=new_tag, files=[])
@@ -220,7 +220,7 @@ class FoldersToTagsModal(QWidget):
self.apply_button.setMinimumWidth(100)
self.apply_button.clicked.connect(self.on_apply)
self.showEvent = self.on_open
self.showEvent = self.on_open # type: ignore
self.root_layout.addWidget(self.title_widget)
self.root_layout.addWidget(self.desc_widget)

View File

@@ -125,7 +125,7 @@ class MirrorEntriesModal(QWidget):
)
def mirror_entries_runnable(self):
mirrored = []
mirrored: list = []
for i, dupe in enumerate(self.lib.dupe_files):
# pb.setValue(i)
# pb.setLabelText(f'Mirroring {i}/{len(self.lib.dupe_files)} Entries')

View File

@@ -292,9 +292,9 @@ class Pagination(QWidget, QObject):
).widget().setHidden(False)
self.start_buffer_layout.itemAt(
i - start_offset
).widget().setText(str(i + 1))
).widget().setText(str(i + 1)) # type: ignore
self._assign_click(
self.start_buffer_layout.itemAt(i - start_offset).widget(),
self.start_buffer_layout.itemAt(i - start_offset).widget(), # type: ignore
i,
)
sbc += 1
@@ -319,11 +319,12 @@ class Pagination(QWidget, QObject):
self.end_buffer_layout.itemAt(
i - end_offset
).widget().setHidden(False)
self.end_buffer_layout.itemAt(i - end_offset).widget().setText(
self.end_buffer_layout.itemAt(i - end_offset).widget().setText( # type: ignore
str(i + 1)
)
self._assign_click(
self.end_buffer_layout.itemAt(i - end_offset).widget(), i
self.end_buffer_layout.itemAt(i - end_offset).widget(), # type: ignore
i,
)
else:
# if self.start_buffer_layout.itemAt(i-1):

View File

@@ -13,6 +13,7 @@ import math
import os
import sys
import time
import typing
import webbrowser
from datetime import datetime as dt
from pathlib import Path
@@ -48,9 +49,9 @@ from humanfriendly import format_timespan
from src.core.enums import SettingItems
from src.core.library import ItemType
from src.core.ts_core import (
from src.core.ts_core import TagStudioCore
from src.core.constants import (
PLAINTEXT_TYPES,
TagStudioCore,
TAG_COLORS,
DATE_FIELDS,
TEXT_FIELDS,
@@ -117,7 +118,7 @@ class NavigationState:
scrollbar_pos: int,
page_index: int,
page_count: int,
search_text: str = None,
search_text: str | None = None,
thumb_size=None,
spacing=None,
) -> None:
@@ -165,17 +166,22 @@ class QtDriver(QObject):
SIGTERM = Signal()
def __init__(self, core, args):
preview_panel: PreviewPanel
def __init__(self, core: TagStudioCore, args):
super().__init__()
self.core: TagStudioCore = core
self.lib = self.core.lib
self.args = args
self.frame_dict: dict = {}
self.nav_frames: list[NavigationState] = []
self.cur_frame_idx: int = -1
# self.main_window = None
# self.main_window = Ui_MainWindow()
self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
self.base_title: str = f"TagStudio {VERSION}{self.branch}"
self.base_title: str = f"TagStudio Alpha {VERSION}{self.branch}"
# self.title_text: str = self.base_title
# self.buffer = {}
self.thumb_job_queue: Queue = Queue()
@@ -193,10 +199,13 @@ class QtDriver(QObject):
f"[QT DRIVER] Config File does not exist creating {str(path)}"
)
logging.info(f"[QT DRIVER] Using Config File {str(path)}")
self.settings = QSettings(str(path), QSettings.IniFormat)
self.settings = QSettings(str(path), QSettings.Format.IniFormat)
else:
self.settings = QSettings(
QSettings.IniFormat, QSettings.UserScope, "TagStudio", "TagStudio"
QSettings.Format.IniFormat,
QSettings.Scope.UserScope,
"TagStudio",
"TagStudio",
)
logging.info(
f"[QT DRIVER] Config File not specified, defaulting to {self.settings.fileName()}"
@@ -230,7 +239,7 @@ class QtDriver(QObject):
signal(SIGTERM, self.signal_handler)
signal(SIGQUIT, self.signal_handler)
def start(self):
def start(self) -> None:
"""Launches the main Qt window."""
loader = QUiLoader()
@@ -257,7 +266,7 @@ class QtDriver(QObject):
# self.main_window = loader.load(home_path)
self.main_window = Ui_MainWindow()
self.main_window.setWindowTitle(self.base_title)
self.main_window.mousePressEvent = self.mouse_navigation
self.main_window.mousePressEvent = self.mouse_navigation # type: ignore
# self.main_window.setStyleSheet(
# f'QScrollBar::{{background:red;}}'
# )
@@ -273,13 +282,13 @@ class QtDriver(QObject):
splash_pixmap = QPixmap(":/images/splash.png")
splash_pixmap.setDevicePixelRatio(self.main_window.devicePixelRatio())
self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint)
self.splash = QSplashScreen(splash_pixmap, Qt.WindowStaysOnTopHint) # type: ignore
# self.splash.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.splash.show()
if os.name == "nt":
appid = "cyanvoxel.tagstudio.9"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid)
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) # type: ignore
if sys.platform != "darwin":
icon = QIcon()
@@ -392,7 +401,7 @@ class QtDriver(QObject):
check_action = QAction("Open library on start", self)
check_action.setCheckable(True)
check_action.setChecked(
self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool)
self.settings.value(SettingItems.START_LOAD_LAST, True, type=bool) # type: ignore
)
check_action.triggered.connect(
lambda checked: self.settings.setValue(
@@ -447,15 +456,14 @@ class QtDriver(QObject):
self.sort_fields_action.setToolTip("Alt+S")
macros_menu.addAction(self.sort_fields_action)
folders_to_tags_action = QAction("Create Tags From Folders", menu_bar)
show_libs_list_action = QAction("Show Recent Libraries", menu_bar)
show_libs_list_action.setCheckable(True)
show_libs_list_action.setChecked(
self.settings.value(SettingItems.WINDOW_SHOW_LIBS, True, type=bool)
self.settings.value(SettingItems.WINDOW_SHOW_LIBS, True, type=bool) # type: ignore
)
show_libs_list_action.triggered.connect(
lambda checked: (
self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked),
self.settings.setValue(SettingItems.WINDOW_SHOW_LIBS, checked), # type: ignore
self.toggle_libs_list(checked),
)
)
@@ -557,9 +565,9 @@ class QtDriver(QObject):
)
)
self.nav_frames: list[NavigationState] = []
self.cur_frame_idx: int = -1
self.cur_query: str = ""
self.nav_frames = []
self.cur_frame_idx = -1
self.cur_query = ""
self.filter_items()
# self.update_thumbs()
@@ -650,9 +658,9 @@ class QtDriver(QObject):
title_text = f"{self.base_title}"
self.main_window.setWindowTitle(title_text)
self.nav_frames: list[NavigationState] = []
self.cur_frame_idx: int = -1
self.cur_query: str = ""
self.nav_frames = []
self.cur_frame_idx = -1
self.cur_query = ""
self.selected.clear()
self.preview_panel.update_widgets()
self.filter_items()
@@ -1016,8 +1024,10 @@ class QtDriver(QObject):
self.update_thumbs()
# logging.info(f'Refresh: {[len(x.contents) for x in self.nav_stack]}, Index {self.cur_page_idx}')
@typing.no_type_check
def purge_item_from_navigation(self, type: ItemType, id: int):
# logging.info(self.nav_frames)
# TODO - types here are ambiguous
for i, frame in enumerate(self.nav_frames, start=0):
while (type, id) in frame.contents:
logging.info(f"Removing {id} from nav stack frame {i}")
@@ -1061,7 +1071,7 @@ class QtDriver(QObject):
sa.setWidgetResizable(True)
sa.setWidget(self.flow_container)
def select_item(self, type: int, id: int, append: bool, bridge: bool):
def select_item(self, type: ItemType, id: int, append: bool, bridge: bool):
"""Selects one or more items in the Thumbnail Grid."""
if append:
# self.selected.append((thumb_index, page_index))
@@ -1164,7 +1174,7 @@ class QtDriver(QObject):
self.thumb_job_queue.put(
(
item_thumb.renderer.render,
(sys.float_info.max, "", base_size, ratio, True),
(sys.float_info.max, "", base_size, ratio, True, True),
)
)
# # Restore Selected Borders
@@ -1262,7 +1272,7 @@ class QtDriver(QObject):
self.thumb_job_queue.put(
(
item_thumb.renderer.render,
(time.time(), filepath, base_size, ratio, False),
(time.time(), filepath, base_size, ratio, False, True),
)
)
else:
@@ -1284,14 +1294,14 @@ class QtDriver(QObject):
self.nav_forward([(ItemType.ENTRY, x[0]) for x in collation_entries])
# self.update_thumbs()
def get_frame_contents(self, index=0, query: str = None):
def get_frame_contents(self, index=0, query: str = ""):
return (
[] if not self.frame_dict[query] else self.frame_dict[query][index],
index,
len(self.frame_dict[query]),
)
def filter_items(self, query=""):
def filter_items(self, query: str = ""):
if self.lib:
# logging.info('Filtering...')
self.main_window.statusbar.showMessage(
@@ -1303,7 +1313,7 @@ class QtDriver(QObject):
# self.filtered_items = self.lib.search_library(query)
# 73601 Entries at 500 size should be 246
all_items = self.lib.search_library(query)
frames = []
frames: list[list[tuple[ItemType, int]]] = []
frame_count = math.ceil(len(all_items) / self.max_results)
for i in range(0, frame_count):
frames.append(
@@ -1349,7 +1359,8 @@ class QtDriver(QObject):
self.settings.endGroup()
self.settings.sync()
def update_libs_list(self, path: str | Path):
@typing.no_type_check
def update_libs_list(self, path: Path):
"""add library to list in SettingItems.LIBS_LIST"""
ITEMS_LIMIT = 5
path = Path(path)
@@ -1392,7 +1403,7 @@ class QtDriver(QObject):
# self.lib.refresh_missing_files()
# title_text = f'{self.base_title} - Library \'{self.lib.library_dir}\''
# self.main_window.setWindowTitle(title_text)
self.update_libs_list(path)
pass
else:
logging.info(
@@ -1401,12 +1412,13 @@ class QtDriver(QObject):
print(f"Library Creation Return Code: {self.lib.create_library(path)}")
self.add_new_files_callback()
self.update_libs_list(path)
title_text = f"{self.base_title} - Library '{self.lib.library_dir}'"
self.main_window.setWindowTitle(title_text)
self.nav_frames: list[NavigationState] = []
self.cur_frame_idx: int = -1
self.cur_query: str = ""
self.nav_frames = []
self.cur_frame_idx = -1
self.cur_query = ""
self.selected.clear()
self.preview_panel.update_widgets()
self.filter_items()
@@ -1444,7 +1456,7 @@ class QtDriver(QObject):
# ('Stretch to Fill','Stretches the media file to fill the entire collage square.'),
# ('Keep Aspect Ratio','Keeps the original media file\'s aspect ratio, filling the rest of the square with black bars.')
# ], prompt='', required=True)
keep_aspect = 0
keep_aspect = False
if mode in [1, 2, 3]:
# TODO: Choose data visualization options here.

View File

@@ -24,7 +24,7 @@ from PySide6.QtCore import (
)
from src.core.library import Library
from src.core.ts_core import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
from src.core.constants import DOC_TYPES, VIDEO_TYPES, IMAGE_TYPES
ERROR = f"[ERROR]"
@@ -86,7 +86,7 @@ class CollageIconRenderer(QObject):
color = "#e22c3c" # Red
if data_only_mode:
pic: Image = Image.new("RGB", size, color)
pic = Image.new("RGB", size, color)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
if not data_only_mode:

View File

@@ -5,9 +5,9 @@
import math
import os
from types import FunctionType
from types import FunctionType, MethodType
from pathlib import Path
from typing import Optional
from typing import Optional, cast, Callable, Any
from PIL import Image, ImageQt
from PySide6.QtCore import Qt, QEvent
@@ -48,7 +48,7 @@ class FieldContainer(QWidget):
# self.editable:bool = editable
self.copy_callback: FunctionType = None
self.edit_callback: FunctionType = None
self.remove_callback: FunctionType = None
self.remove_callback: Callable = None
button_size = 24
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
@@ -129,7 +129,7 @@ class FieldContainer(QWidget):
# self.set_inner_widget(mode)
def set_copy_callback(self, callback: Optional[FunctionType]):
def set_copy_callback(self, callback: Optional[MethodType]):
try:
self.copy_button.clicked.disconnect()
except RuntimeError:
@@ -138,7 +138,7 @@ class FieldContainer(QWidget):
self.copy_callback = callback
self.copy_button.clicked.connect(callback)
def set_edit_callback(self, callback: Optional[FunctionType]):
def set_edit_callback(self, callback: Optional[MethodType]):
try:
self.edit_button.clicked.disconnect()
except RuntimeError:
@@ -147,7 +147,7 @@ class FieldContainer(QWidget):
self.edit_callback = callback
self.edit_button.clicked.connect(callback)
def set_remove_callback(self, callback: Optional[FunctionType]):
def set_remove_callback(self, callback: Optional[Callable]):
try:
self.remove_button.clicked.disconnect()
except RuntimeError:
@@ -168,7 +168,7 @@ class FieldContainer(QWidget):
def get_inner_widget(self) -> Optional["FieldWidget"]:
if self.field_layout.itemAt(0):
return self.field_layout.itemAt(0).widget()
return cast(FieldWidget, self.field_layout.itemAt(0).widget())
return None
def set_title(self, title: str):

View File

@@ -23,8 +23,9 @@ from PySide6.QtWidgets import (
QCheckBox,
)
from src.core.library import ItemType, Library, Entry
from src.core.ts_core import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES
from src.core.constants import AUDIO_TYPES, VIDEO_TYPES, IMAGE_TYPES
from src.qt.flowlayout import FlowWidget
from src.qt.helpers.file_opener import FileOpenerHelper
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -181,7 +182,7 @@ class ItemThumb(FlowWidget):
lambda ts, i, s, ext: (
self.update_thumb(ts, image=i),
self.update_size(ts, size=s),
self.set_extension(ext),
self.set_extension(ext), # type: ignore
)
)
self.thumb_button.setFlat(True)
@@ -388,7 +389,7 @@ class ItemThumb(FlowWidget):
self.thumb_button.setMinimumSize(size)
self.thumb_button.setMaximumSize(size)
def update_clickable(self, clickable: FunctionType = None):
def update_clickable(self, clickable: typing.Callable):
"""Updates attributes of a thumbnail element."""
# logging.info(f'[GUI] Updating Click Event for element {id(element)}: {id(clickable) if clickable else None}')
try:

View File

@@ -19,9 +19,9 @@ class PanelModal(QWidget):
widget: "PanelWidget",
title: str,
window_title: str,
done_callback: FunctionType = None,
done_callback: Callable = None,
# cancel_callback:FunctionType=None,
save_callback: FunctionType = None,
save_callback: Callable = None,
has_save: bool = False,
):
# [Done]

View File

@@ -6,10 +6,10 @@ import logging
import os
import time
import typing
from types import FunctionType
from datetime import datetime as dt
import cv2
import rawpy
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import Signal, Qt, QSize
@@ -30,7 +30,7 @@ from humanfriendly import format_size
from src.core.enums import SettingItems, Theme
from src.core.library import Entry, ItemType, Library
from src.core.ts_core import VIDEO_TYPES, IMAGE_TYPES
from src.core.constants import VIDEO_TYPES, IMAGE_TYPES, RAW_IMAGE_TYPES
from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file
from src.qt.modals.add_field import AddFieldModal
from src.qt.widgets.thumb_renderer import ThumbRenderer
@@ -67,8 +67,8 @@ class PreviewPanel(QWidget):
self.isOpen: bool = False
# self.filepath = None
# self.item = None # DEPRECATED, USE self.selected
self.common_fields = []
self.mixed_fields = []
self.common_fields: list = []
self.mixed_fields: list = []
self.selected: list[tuple[ItemType, int]] = [] # New way of tracking items
self.tag_callback = None
self.containers: list[QWidget] = []
@@ -91,9 +91,11 @@ class PreviewPanel(QWidget):
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
self.tr = ThumbRenderer()
self.tr.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i)))
self.tr.updated_ratio.connect(
self.thumb_renderer = ThumbRenderer()
self.thumb_renderer.updated.connect(
lambda ts, i, s: (self.preview_img.setIcon(i))
)
self.thumb_renderer.updated_ratio.connect(
lambda ratio: (
self.set_image_ratio(ratio),
self.update_image_size(
@@ -174,14 +176,17 @@ class PreviewPanel(QWidget):
info_layout.addWidget(scroll_area)
# keep list of rendered libraries to avoid needless re-rendering
self.render_libs = set()
self.render_libs: set = set()
self.libs_layout = QVBoxLayout()
self.fill_libs_widget(self.libs_layout)
self.libs_flow_container: QWidget = QWidget()
self.libs_flow_container.setObjectName("librariesList")
self.libs_flow_container.setLayout(self.libs_layout)
self.libs_flow_container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
self.libs_flow_container.setSizePolicy(
QSizePolicy.Preferred, # type: ignore
QSizePolicy.Maximum, # type: ignore
)
# set initial visibility based on settings
if not self.driver.settings.value(
@@ -231,7 +236,7 @@ class PreviewPanel(QWidget):
settings.beginGroup(SettingItems.LIBS_LIST)
lib_items: dict[str, tuple[str, str]] = {}
for item_tstamp in settings.allKeys():
val = settings.value(item_tstamp)
val: str = settings.value(item_tstamp) # type: ignore
cut_val = val
if len(val) > 45:
cut_val = f"{val[0:10]} ... {val[-10:]}"
@@ -259,13 +264,13 @@ class PreviewPanel(QWidget):
if child.widget() is not None:
child.widget().deleteLater()
elif child.layout() is not None:
clear_layout(child.layout())
clear_layout(child.layout()) # type: ignore
# remove any potential previous items
clear_layout(layout)
label = QLabel("Recent Libraries")
label.setAlignment(Qt.AlignCenter)
label.setAlignment(Qt.AlignCenter) # type: ignore
row_layout = QHBoxLayout()
row_layout.addWidget(label)
@@ -346,8 +351,8 @@ class PreviewPanel(QWidget):
# logging.info(f'')
# self.preview_img.setMinimumSize(64,64)
adj_width = size[0]
adj_height = size[1]
adj_width: float = size[0]
adj_height: float = size[1]
# Landscape
if self.image_ratio > 1:
# logging.info('Landscape')
@@ -369,8 +374,8 @@ class PreviewPanel(QWidget):
# self.preview_img.setMinimumSize(s)
# self.preview_img.setMaximumSize(s_max)
adj_size = QSize(adj_width, adj_height)
self.img_button_size = (adj_width, adj_height)
adj_size = QSize(int(adj_width), int(adj_height))
self.img_button_size = (int(adj_width), int(adj_height))
self.preview_img.setMaximumSize(adj_size)
self.preview_img.setIconSize(adj_size)
# self.preview_img.setMinimumSize(adj_size)
@@ -378,7 +383,7 @@ class PreviewPanel(QWidget):
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
# if type(self.item) == Entry:
# filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}')
# self.tr.render_big(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio())
# self.thumb_renderer.render(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio(),update_on_ratio_change=True)
# logging.info(f' Img Aspect Ratio: {self.image_ratio}')
# logging.info(f' Max Button Size: {size}')
@@ -441,7 +446,14 @@ class PreviewPanel(QWidget):
self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
ratio: float = self.devicePixelRatio()
self.tr.render_big(time.time(), "", (512, 512), ratio, True)
self.thumb_renderer.render(
time.time(),
"",
(512, 512),
ratio,
True,
update_on_ratio_change=True,
)
try:
self.preview_img.clicked.disconnect()
except RuntimeError:
@@ -465,7 +477,13 @@ class PreviewPanel(QWidget):
self.file_label.setFilePath(filepath)
window_title = filepath
ratio: float = self.devicePixelRatio()
self.tr.render_big(time.time(), filepath, (512, 512), ratio)
self.thumb_renderer.render(
time.time(),
filepath,
(512, 512),
ratio,
update_on_ratio_change=True,
)
self.file_label.setText("\u200b".join(filepath))
self.file_label.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -486,30 +504,21 @@ class PreviewPanel(QWidget):
image = None
if extension in IMAGE_TYPES:
image = Image.open(filepath)
if image.mode == "RGBA":
new_bg = Image.new("RGB", image.size, color="#1e1e1e")
new_bg.paste(image, mask=image.getchannel(3))
image = new_bg
if image.mode != "RGB":
image = image.convert(mode="RGB")
elif extension in RAW_IMAGE_TYPES:
with rawpy.imread(filepath) as raw:
rgb = raw.postprocess()
image = Image.new(
"L", (rgb.shape[1], rgb.shape[0]), color="black"
)
elif extension in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
# Stats for specific file types are displayed here.
if extension in (IMAGE_TYPES + VIDEO_TYPES):
if extension in (IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES):
self.dimensions_label.setText(
f"{extension.upper()}{format_size(os.stat(filepath).st_size)}\n{image.width} x {image.height} px"
)
@@ -517,9 +526,6 @@ class PreviewPanel(QWidget):
self.dimensions_label.setText(f"{extension.upper()}")
if not image:
self.dimensions_label.setText(
f"{extension.upper()}{format_size(os.stat(filepath).st_size)}"
)
raise UnidentifiedImageError
except (
@@ -528,6 +534,9 @@ class PreviewPanel(QWidget):
cv2.error,
DecompressionBombError,
) as e:
self.dimensions_label.setText(
f"{extension.upper()}{format_size(os.stat(filepath).st_size)}"
)
logging.info(
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
@@ -574,7 +583,14 @@ class PreviewPanel(QWidget):
self.preview_img.setCursor(Qt.CursorShape.ArrowCursor)
ratio: float = self.devicePixelRatio()
self.tr.render_big(time.time(), "", (512, 512), ratio, True)
self.thumb_renderer.render(
time.time(),
"",
(512, 512),
ratio,
True,
update_on_ratio_change=True,
)
try:
self.preview_img.clicked.disconnect()
except RuntimeError:
@@ -656,7 +672,7 @@ class PreviewPanel(QWidget):
# filepath = os.path.normpath(f'{self.lib.library_dir}/{item.path}/{item.filename}')
# window_title = filepath
# ratio: float = self.devicePixelRatio()
# self.tr.render_big(time.time(), filepath, (512, 512), ratio)
# self.thumb_renderer.render(time.time(), filepath, (512, 512), ratio,update_on_ratio_change=True)
# self.file_label.setText("\u200b".join(filepath))
# # TODO: Deal with this later.
@@ -794,7 +810,6 @@ class PreviewPanel(QWidget):
# container.set_editable(True)
container.set_inline(False)
# Normalize line endings in any text content.
text: str = ""
if not mixed:
text = self.lib.get_field_attr(field, "content").replace("\r", "\n")
else:
@@ -834,7 +849,6 @@ class PreviewPanel(QWidget):
# container.set_editable(True)
container.set_inline(False)
# Normalize line endings in any text content.
text: str = ""
if not mixed:
text = self.lib.get_field_attr(field, "content").replace("\r", "\n")
else:
@@ -875,7 +889,7 @@ class PreviewPanel(QWidget):
self.lib.get_field_attr(field, "content")
)
title = f"{self.lib.get_field_attr(field, 'name')} (Collation)"
text: str = f"{collation.title} ({len(collation.e_ids_and_pages)} Items)"
text = f"{collation.title} ({len(collation.e_ids_and_pages)} Items)"
if len(self.selected) == 1:
text += f" - Page {collation.e_ids_and_pages[[x[0] for x in collation.e_ids_and_pages].index(self.selected[0][1])][1]}"
inner_container = TextWidget(title, text)
@@ -948,10 +962,11 @@ class PreviewPanel(QWidget):
container.set_remove_callback(
lambda: self.remove_message_box(prompt=prompt, callback=callback)
)
container.edit_button.setHidden(True)
container.setHidden(False)
self.place_add_field_button()
def remove_field(self, field: object):
def remove_field(self, field: dict):
"""Removes a field from all selected Entries, given a field object."""
for item_pair in self.selected:
if item_pair[0] == ItemType.ENTRY:
@@ -973,7 +988,7 @@ class PreviewPanel(QWidget):
)
pass
def update_field(self, field: object, content):
def update_field(self, field: dict, content):
"""Removes a field from all selected Entries, given a field object."""
field = dict(field)
for item_pair in self.selected:
@@ -989,7 +1004,7 @@ class PreviewPanel(QWidget):
)
pass
def remove_message_box(self, prompt: str, callback: FunctionType) -> int:
def remove_message_box(self, prompt: str, callback: typing.Callable) -> None:
remove_mb = QMessageBox()
remove_mb.setText(prompt)
remove_mb.setWindowTitle("Remove Field")

View File

@@ -78,7 +78,7 @@ class TagBoxWidget(FieldWidget):
tsp.tag_chosen.connect(lambda x: self.add_tag_callback(x))
self.add_modal = PanelModal(tsp, title, "Add Tags")
self.add_button.clicked.connect(
lambda: (tsp.update_tags(), self.add_modal.show())
lambda: (tsp.update_tags(), self.add_modal.show()) # type: ignore
)
self.set_tags(tags)
@@ -137,7 +137,6 @@ class TagBoxWidget(FieldWidget):
has_save=True,
)
# self.edit_modal.widget.update_display_name.connect(lambda t: self.edit_modal.title_widget.setText(t))
panel: BuildTagPanel = self.edit_modal.widget
self.edit_modal.saved.connect(lambda: self.lib.update_tag(btp.build_tag()))
# panel.tag_updated.connect(lambda tag: self.lib.update_tag(tag))
self.edit_modal.show()
@@ -149,7 +148,7 @@ class TagBoxWidget(FieldWidget):
f"[TAG BOX WIDGET] ADD TAG CALLBACK: T:{tag_id} to E:{self.item.id}"
)
logging.info(f"[TAG BOX WIDGET] SELECTED T:{self.driver.selected}")
id = list(self.field.keys())[0]
id: int = list(self.field.keys())[0] # type: ignore
for x in self.driver.selected:
self.driver.lib.get_entry(x[1]).add_tag(
self.driver.lib, tag_id, field_id=id, field_index=-1
@@ -170,9 +169,9 @@ class TagBoxWidget(FieldWidget):
def edit_tag_callback(self, tag: Tag):
self.lib.update_tag(tag)
def remove_tag(self, tag_id):
def remove_tag(self, tag_id: int):
logging.info(f"[TAG BOX WIDGET] SELECTED T:{self.driver.selected}")
id = list(self.field.keys())[0]
id: int = list(self.field.keys())[0] # type: ignore
for x in self.driver.selected:
index = self.driver.lib.get_field_index_in_entry(
self.driver.lib.get_entry(x[1]), id

View File

@@ -5,7 +5,7 @@
from PySide6 import QtCore
from PySide6.QtCore import QEvent
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath, QPaintEvent
from PySide6.QtWidgets import QWidget, QPushButton
@@ -18,7 +18,7 @@ class ThumbButton(QPushButton):
# self.clicked.connect(lambda checked: self.set_selected(True))
def paintEvent(self, event: QEvent) -> None:
def paintEvent(self, event: QPaintEvent) -> None:
super().paintEvent(event)
if self.hovered or self.selected:
painter = QPainter()

View File

@@ -3,37 +3,43 @@
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
import ctypes
import logging
import math
import os
from pathlib import Path
import cv2
import rawpy
from pillow_heif import register_heif_opener, register_avif_opener
from PIL import (
Image,
ImageChops,
UnidentifiedImageError,
ImageQt,
ImageDraw,
ImageFont,
ImageEnhance,
ImageOps,
ImageFile,
)
from PIL.Image import DecompressionBombError
from PySide6.QtCore import QObject, Signal, QSize
from PySide6.QtGui import QPixmap
from src.core.ts_core import PLAINTEXT_TYPES, VIDEO_TYPES, IMAGE_TYPES
from src.qt.helpers.gradient import four_corner_gradient_background
from src.core.constants import (
PLAINTEXT_TYPES,
VIDEO_TYPES,
IMAGE_TYPES,
RAW_IMAGE_TYPES,
)
ImageFile.LOAD_TRUNCATED_IMAGES = True
ERROR = f"[ERROR]"
WARNING = f"[WARNING]"
INFO = f"[INFO]"
ERROR = "[ERROR]"
WARNING = "[WARNING]"
INFO = "[INFO]"
logging.basicConfig(format="%(message)s", level=logging.INFO)
register_heif_opener()
register_avif_opener()
class ThumbRenderer(QObject):
@@ -44,50 +50,38 @@ class ThumbRenderer(QObject):
# updatedSize = Signal(QSize)
thumb_mask_512: Image.Image = Image.open(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_mask_512.png"
)
Path(__file__).parents[3] / "resources/qt/images/thumb_mask_512.png"
)
thumb_mask_512.load()
thumb_mask_hl_512: Image.Image = Image.open(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_mask_hl_512.png"
)
Path(__file__).parents[3] / "resources/qt/images/thumb_mask_hl_512.png"
)
thumb_mask_hl_512.load()
thumb_loading_512: Image.Image = Image.open(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_loading_512.png"
)
Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png"
)
thumb_loading_512.load()
thumb_broken_512: Image.Image = Image.open(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_broken_512.png"
)
Path(__file__).parents[3] / "resources/qt/images/thumb_broken_512.png"
)
thumb_broken_512.load()
thumb_file_default_512: Image.Image = Image.open(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/images/thumb_file_default_512.png"
)
Path(__file__).parents[3] / "resources/qt/images/thumb_file_default_512.png"
)
thumb_file_default_512.load()
# thumb_debug: Image.Image = Image.open(os.path.normpath(
# thumb_debug: Image.Image = Image.open(Path(
# f'{Path(__file__).parents[2]}/resources/qt/images/temp.jpg'))
# thumb_debug.load()
# TODO: Make dynamic font sized given different pixel ratios
font_pixel_ratio: float = 1
ext_font = ImageFont.truetype(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/fonts/Oxanium-Bold.ttf"
),
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
math.floor(12 * font_pixel_ratio),
)
@@ -96,44 +90,35 @@ class ThumbRenderer(QObject):
timestamp: float,
filepath,
base_size: tuple[int, int],
pixelRatio: float,
isLoading=False,
pixel_ratio: float,
is_loading=False,
gradient=False,
update_on_ratio_change=False,
):
"""Renders an entry/element thumbnail for the GUI."""
adj_size: int = 1
image = None
pixmap = None
final = None
"""Internal renderer. Renders an entry/element thumbnail for the GUI."""
image: Image.Image = None
pixmap: QPixmap = None
final: Image.Image = None
extension: str = None
broken_thumb = False
# adj_font_size = math.floor(12 * pixelRatio)
if ThumbRenderer.font_pixel_ratio != pixelRatio:
ThumbRenderer.font_pixel_ratio = pixelRatio
resampling_method = Image.Resampling.BILINEAR
if ThumbRenderer.font_pixel_ratio != pixel_ratio:
ThumbRenderer.font_pixel_ratio = pixel_ratio
ThumbRenderer.ext_font = ImageFont.truetype(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/fonts/Oxanium-Bold.ttf"
),
Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf",
math.floor(12 * ThumbRenderer.font_pixel_ratio),
)
if isLoading or filepath:
adj_size = math.ceil(base_size[0] * pixelRatio)
if isLoading:
li: Image.Image = ThumbRenderer.thumb_loading_512.resize(
adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio)
if is_loading:
final = ThumbRenderer.thumb_loading_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
)
qim = ImageQt.ImageQt(li)
qim = ImageQt.ImageQt(final)
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixelRatio)
pixmap.setDevicePixelRatio(pixel_ratio)
if update_on_ratio_change:
self.updated_ratio.emit(1)
elif filepath:
mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
).getchannel(3)
hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
)
extension = os.path.splitext(filepath)[1][1:].lower()
try:
@@ -141,19 +126,36 @@ class ThumbRenderer(QObject):
if extension in IMAGE_TYPES:
try:
image = Image.open(filepath)
# image = self.thumb_debug
if image.mode != "RGB" and image.mode != "RGBA":
image = image.convert(mode="RGBA")
if image.mode == "RGBA":
# logging.info(image.getchannel(3).tobytes())
new_bg = Image.new("RGB", image.size, color="#1e1e1e")
new_bg.paste(image, mask=image.getchannel(3))
image = new_bg
if image.mode != "RGB":
image = image.convert(mode="RGB")
image = ImageOps.exif_transpose(image)
except DecompressionBombError as e:
logging.info(
f"[ThumbRenderer][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {filepath} (because of {e})"
)
elif extension in RAW_IMAGE_TYPES:
try:
with rawpy.imread(filepath) as raw:
rgb = raw.postprocess()
image = Image.frombytes(
"RGB",
(rgb.shape[1], rgb.shape[0]),
rgb,
decoder_name="raw",
)
except DecompressionBombError as e:
logging.info(
f"[ThumbRenderer]{WARNING} Couldn't Render thumbnail for {filepath} (because of {e})"
)
except rawpy._rawpy.LibRawIOError:
logging.info(
f"[ThumbRenderer]{ERROR} Couldn't Render thumbnail for raw image {filepath}"
)
# Videos =======================================================
@@ -175,18 +177,31 @@ class ThumbRenderer(QObject):
# Plain Text ===================================================
elif extension in PLAINTEXT_TYPES:
try:
text: str = extension
with open(filepath, "r", encoding="utf-8") as text_file:
text = text_file.read(256)
bg = Image.new("RGB", (256, 256), color="#1e1e1e")
draw = ImageDraw.Draw(bg)
draw.text((16, 16), text, file=(255, 255, 255))
image = bg
except:
logging.info(
f"[ThumbRenderer][ERROR]: Coulnd't render thumbnail for {filepath}"
)
with open(filepath, "r", encoding="utf-8") as text_file:
text = text_file.read(256)
bg = Image.new("RGB", (256, 256), color="#1e1e1e")
draw = ImageDraw.Draw(bg)
draw.text((16, 16), text, file=(255, 255, 255))
image = bg
# 3D ===========================================================
# elif extension == 'stl':
# # Create a new plot
# matplotlib.use('agg')
# figure = plt.figure()
# axes = figure.add_subplot(projection='3d')
# # Load the STL files and add the vectors to the plot
# your_mesh = mesh.Mesh.from_file(filepath)
# poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors)
# poly_collection.set_color((0,0,1)) # play with color
# scale = your_mesh.points.flatten()
# axes.auto_scale_xyz(scale, scale, scale)
# axes.add_collection3d(poly_collection)
# # plt.show()
# img_buf = io.BytesIO()
# plt.savefig(img_buf, format='png')
# image = Image.open(img_buf)
# No Rendered Thumbnail ========================================
else:
image = ThumbRenderer.thumb_file_default_512.resize(
@@ -206,307 +221,73 @@ class ThumbRenderer(QObject):
new_y = adj_size
new_x = math.ceil(adj_size * (orig_x / orig_y))
# img_ratio = new_x / new_y
image = image.resize((new_x, new_y), resample=Image.Resampling.BILINEAR)
if update_on_ratio_change:
self.updated_ratio.emit(new_x / new_y)
if image.size != (adj_size, adj_size):
# Old 1 color method.
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
# bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
# bg.thumbnail((1, 1))
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
# Small gradient background. Looks decent, and is only a one-liner.
# bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR)
# Four-Corner Gradient Background.
# Not exactly a one-liner, but it's (subjectively) really cool.
tl = image.getpixel((0, 0))
tr = image.getpixel(((image.size[0] - 1), 0))
bl = image.getpixel((0, (image.size[1] - 1)))
br = image.getpixel(((image.size[0] - 1), (image.size[1] - 1)))
bg = Image.new(mode="RGB", size=(2, 2))
bg.paste(tl, (0, 0, 2, 2))
bg.paste(tr, (1, 0, 2, 2))
bg.paste(bl, (0, 1, 2, 2))
bg.paste(br, (1, 1, 2, 2))
bg = bg.resize(
(adj_size, adj_size), resample=Image.Resampling.BICUBIC
)
bg.paste(
image,
box=(
(adj_size - image.size[0]) // 2,
(adj_size - image.size[1]) // 2,
),
)
bg.putalpha(mask)
final = bg
else:
image.putalpha(mask)
final = image
hl_soft = hl.copy()
hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5))
final.paste(
ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3)
resampling_method = (
Image.Resampling.NEAREST
if max(image.size[0], image.size[1])
< max(base_size[0], base_size[1])
else Image.Resampling.BILINEAR
)
# hl_add = hl.copy()
# hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25))
# final.paste(hl_add, mask=hl_add.getchannel(3))
except (UnidentifiedImageError, FileNotFoundError, cv2.error):
broken_thumb = True
final = ThumbRenderer.thumb_broken_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
)
qim = ImageQt.ImageQt(final)
if image:
image.close()
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixelRatio)
if pixmap:
self.updated.emit(timestamp, pixmap, QSize(*base_size), extension)
else:
self.updated.emit(timestamp, QPixmap(), QSize(*base_size), extension)
def render_big(
self,
timestamp: float,
filepath,
base_size: tuple[int, int],
pixelRatio: float,
isLoading=False,
):
"""Renders a large, non-square entry/element thumbnail for the GUI."""
adj_size: int = 1
image: Image.Image = None
pixmap: QPixmap = None
final: Image.Image = None
extension: str = None
broken_thumb = False
img_ratio = 1
# adj_font_size = math.floor(12 * pixelRatio)
if ThumbRenderer.font_pixel_ratio != pixelRatio:
ThumbRenderer.font_pixel_ratio = pixelRatio
ThumbRenderer.ext_font = ImageFont.truetype(
os.path.normpath(
f"{Path(__file__).parents[3]}/resources/qt/fonts/Oxanium-Bold.ttf"
),
math.floor(12 * ThumbRenderer.font_pixel_ratio),
)
if isLoading or filepath:
adj_size = math.ceil(max(base_size[0], base_size[1]) * pixelRatio)
if isLoading:
adj_size = math.ceil((512 * pixelRatio))
final: Image.Image = ThumbRenderer.thumb_loading_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
)
qim = ImageQt.ImageQt(final)
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixelRatio)
self.updated_ratio.emit(1)
elif filepath:
# mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
# (adj_size, adj_size), resample=Image.Resampling.BILINEAR).getchannel(3)
# hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize(
# (adj_size, adj_size), resample=Image.Resampling.BILINEAR)
extension = os.path.splitext(filepath)[1][1:].lower()
try:
# Images =======================================================
if extension in IMAGE_TYPES:
try:
image = Image.open(filepath)
# image = self.thumb_debug
if image.mode == "RGBA":
# logging.info(image.getchannel(3).tobytes())
new_bg = Image.new("RGB", image.size, color="#1e1e1e")
new_bg.paste(image, mask=image.getchannel(3))
image = new_bg
if image.mode != "RGB":
image = image.convert(mode="RGB")
image = ImageOps.exif_transpose(image)
except DecompressionBombError as e:
logging.info(
f"[ThumbRenderer][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
)
# Videos =======================================================
elif extension in VIDEO_TYPES:
video = cv2.VideoCapture(filepath)
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
success, frame = video.read()
if not success:
# Depending on the video format, compression, and frame
# count, seeking halfway does not work and the thumb
# must be pulled from the earliest available frame.
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
# Plain Text ===================================================
elif extension in PLAINTEXT_TYPES:
try:
text: str = extension
with open(filepath, "r", encoding="utf-8") as text_file:
text = text_file.read(256)
bg = Image.new("RGB", (256, 256), color="#1e1e1e")
draw = ImageDraw.Draw(bg)
draw.text((16, 16), text, file=(255, 255, 255))
image = bg
except:
logging.info(
f"[ThumbRenderer][ERROR]: Coulnd't render thumbnail for {filepath}"
)
# No Rendered Thumbnail ========================================
else:
image = ThumbRenderer.thumb_file_default_512.resize(
image = image.resize((new_x, new_y), resample=resampling_method)
if gradient:
mask: Image.Image = ThumbRenderer.thumb_mask_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
).getchannel(3)
hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
)
if not image:
raise UnidentifiedImageError
orig_x, orig_y = image.size
if orig_x < adj_size and orig_y < adj_size:
new_x, new_y = (adj_size, adj_size)
if orig_x > orig_y:
new_x = adj_size
new_y = math.ceil(adj_size * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = adj_size
new_x = math.ceil(adj_size * (orig_x / orig_y))
final = four_corner_gradient_background(image, adj_size, mask, hl)
else:
new_x, new_y = (adj_size, adj_size)
if orig_x > orig_y:
new_x = adj_size
new_y = math.ceil(adj_size * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = adj_size
new_x = math.ceil(adj_size * (orig_x / orig_y))
self.updated_ratio.emit(new_x / new_y)
image = image.resize((new_x, new_y), resample=Image.Resampling.BILINEAR)
# image = image.resize(
# (new_x, new_y), resample=Image.Resampling.BILINEAR)
# if image.size != (adj_size, adj_size):
# # Old 1 color method.
# # bg_col = image.copy().resize((1, 1)).getpixel((0,0))
# # bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
# # bg.thumbnail((1, 1))
# # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
# # Small gradient background. Looks decent, and is only a one-liner.
# # bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR)
# # Four-Corner Gradient Background.
# # Not exactly a one-liner, but it's (subjectively) really cool.
# tl = image.getpixel((0, 0))
# tr = image.getpixel(((image.size[0]-1), 0))
# bl = image.getpixel((0, (image.size[1]-1)))
# br = image.getpixel(((image.size[0]-1), (image.size[1]-1)))
# bg = Image.new(mode='RGB', size=(2, 2))
# bg.paste(tl, (0, 0, 2, 2))
# bg.paste(tr, (1, 0, 2, 2))
# bg.paste(bl, (0, 1, 2, 2))
# bg.paste(br, (1, 1, 2, 2))
# bg = bg.resize((adj_size, adj_size),
# resample=Image.Resampling.BICUBIC)
# bg.paste(image, box=(
# (adj_size-image.size[0])//2, (adj_size-image.size[1])//2))
# bg.putalpha(mask)
# final = bg
# else:
# image.putalpha(mask)
# final = image
# hl_soft = hl.copy()
# hl_soft.putalpha(ImageEnhance.Brightness(
# hl.getchannel(3)).enhance(.5))
# final.paste(ImageChops.soft_light(final, hl_soft),
# mask=hl_soft.getchannel(3))
# hl_add = hl.copy()
# hl_add.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(.25))
# final.paste(hl_add, mask=hl_add.getchannel(3))
scalar = 4
rec: Image.Image = Image.new(
"RGB", tuple([d * scalar for d in image.size]), "black"
)
draw = ImageDraw.Draw(rec)
draw.rounded_rectangle(
(0, 0) + rec.size,
(base_size[0] // 32) * scalar * pixelRatio,
fill="red",
)
rec = rec.resize(
tuple([d // scalar for d in rec.size]),
resample=Image.Resampling.BILINEAR,
)
# final = image
final = Image.new("RGBA", image.size, (0, 0, 0, 0))
# logging.info(rec.size)
# logging.info(image.size)
final.paste(image, mask=rec.getchannel(0))
except (UnidentifiedImageError, FileNotFoundError, cv2.error):
broken_thumb = True
self.updated_ratio.emit(1)
scalar = 4
rec: Image.Image = Image.new(
"RGB",
tuple([d * scalar for d in image.size]), # type: ignore
"black",
)
draw = ImageDraw.Draw(rec)
draw.rounded_rectangle(
(0, 0) + rec.size,
(base_size[0] // 32) * scalar * pixel_ratio,
fill="red",
)
rec = rec.resize(
tuple([d // scalar for d in rec.size]),
resample=Image.Resampling.BILINEAR,
)
final = Image.new("RGBA", image.size, (0, 0, 0, 0))
final.paste(image, mask=rec.getchannel(0))
except (
UnidentifiedImageError,
FileNotFoundError,
cv2.error,
DecompressionBombError,
UnicodeDecodeError,
) as e:
if e is not UnicodeDecodeError:
logging.info(
f"[ThumbRenderer]{ERROR}: Couldn't render thumbnail for {filepath} ({e})"
)
if update_on_ratio_change:
self.updated_ratio.emit(1)
final = ThumbRenderer.thumb_broken_512.resize(
(adj_size, adj_size), resample=Image.Resampling.BILINEAR
(adj_size, adj_size), resample=resampling_method
)
# if extension in VIDEO_TYPES + ['gif', 'apng'] or broken_thumb:
# idk = ImageDraw.Draw(final)
# # idk.textlength(file_type)
# ext_offset_x = idk.textlength(
# text=extension.upper(), font=ThumbRenderer.ext_font) / 2
# ext_offset_x = math.floor(ext_offset_x * (1/pixelRatio))
# x_margin = math.floor(
# (adj_size-((base_size[0]//6)+ext_offset_x) * pixelRatio))
# y_margin = math.floor(
# (adj_size-((base_size[0]//8)) * pixelRatio))
# stroke_width = round(2 * pixelRatio)
# fill = 'white' if not broken_thumb else '#E32B41'
# idk.text((x_margin, y_margin), extension.upper(
# ), fill=fill, font=ThumbRenderer.ext_font, stroke_width=stroke_width, stroke_fill=(0, 0, 0))
qim = ImageQt.ImageQt(final)
if image:
image.close()
pixmap = QPixmap.fromImage(qim)
pixmap.setDevicePixelRatio(pixelRatio)
pixmap.setDevicePixelRatio(pixel_ratio)
if pixmap:
# logging.info(final.size)
# self.updated.emit(pixmap, QSize(*final.size))
self.updated.emit(
timestamp,
pixmap,
QSize(
math.ceil(adj_size * 1 / pixelRatio),
math.ceil(final.size[1] * 1 / pixelRatio),
math.ceil(adj_size / pixel_ratio),
math.ceil(final.size[1] / pixel_ratio),
),
extension,
)

View File

@@ -5,7 +5,7 @@
"""TagStudio launcher."""
from src.core.ts_core import TagStudioCore
from src.cli.ts_cli import CliDriver
from src.cli.ts_cli import CliDriver # type: ignore
from src.qt.ts_qt import QtDriver
import argparse
import traceback

View File

@@ -1,18 +1,18 @@
from src.core.library import Tag
class TestTags:
def test_construction(self):
tag = Tag(
id=1,
name="Tag Name",
shorthand="TN",
aliases=["First A", "Second A"],
subtags_ids=[2, 3, 4],
color="",
)
assert tag
def test_construction():
tag = Tag(
id=1,
name="Tag Name",
shorthand="TN",
aliases=["First A", "Second A"],
subtags_ids=[2, 3, 4],
color="",
)
assert tag
def test_empty_construction(self):
tag = Tag(id=1, name="", shorthand="", aliases=[], subtags_ids=[], color="")
assert tag
def test_empty_construction():
tag = Tag(id=1, name="", shorthand="", aliases=[], subtags_ids=[], color="")
assert tag