mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-05-25 18:22:46 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57e27bb51f | ||
|
|
66ec0913b6 | ||
|
|
6357fea8db | ||
|
|
491ebb6714 | ||
|
|
385b4117db | ||
|
|
be3992f655 | ||
|
|
18becd62a3 | ||
|
|
699ecd367c | ||
|
|
9d7609a8e5 | ||
|
|
e94c4871d7 | ||
|
|
02bf15e080 | ||
|
|
5f60ec1702 | ||
|
|
cdf2581f84 | ||
|
|
af8b4e3872 | ||
|
|
ac9dd5879e | ||
|
|
badcd72bea | ||
|
|
8733c8d301 | ||
|
|
4726f1fc63 | ||
|
|
1461f2ee70 | ||
|
|
1bfc24b70f | ||
|
|
c09f50c568 | ||
|
|
66aecf2030 | ||
|
|
dc188264f9 | ||
|
|
6e56f13eda | ||
|
|
c9ea25b940 | ||
|
|
e814d09c60 | ||
|
|
4b1119ecba | ||
|
|
ad850cba94 | ||
|
|
b6848bb81f | ||
|
|
fb7c73d96b |
35
.github/workflows/mypy.yaml
vendored
Normal file
35
.github/workflows/mypy.yaml
vendored
Normal 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
|
||||
10
README.md
10
README.md
@@ -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)._
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
137
tagstudio/src/core/constants.py
Normal file
137
tagstudio/src/core/constants.py
Normal 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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("_", " ")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
50
tagstudio/src/qt/helpers/gradient.py
Normal file
50
tagstudio/src/qt/helpers/gradient.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user