Compare commits

...

24 Commits

Author SHA1 Message Date
Travis Abendshien cd6e427e02 fix: remove file_renderer.py 2026-07-24 20:17:26 -07:00
Travis Abendshien 0c21c45cb4 refactor: name and license consistency, misc fixes 2026-07-24 20:07:46 -07:00
Travis Abendshien d2bf445a13 fix: move jxl optional import to raster_image.py 2026-07-24 19:01:41 -07:00
Travis Abendshien 8995ef6caf refactor: consolidate embedded archive thumbnail functions 2026-07-24 18:56:29 -07:00
Travis Abendshien 068add29df refactor: split ThumbRenderer class 2026-07-24 17:30:18 -07:00
Travis Abendshien f24653e030 docs: update styling 2026-07-22 18:31:18 -07:00
Travis Abendshien c30dee799a docs: update Nix verbage 2026-07-22 18:30:57 -07:00
Travis Abendshien 15230cc369 fix: assign new tag ID to aliases before adding them (#1455) 2026-07-21 20:46:08 -07:00
Travis Abendshien c3b6bec2ff chore: pass ripgrep label text to constructor 2026-07-19 19:49:37 -07:00
Travis Abendshien 58496a7d2d build: add nightly icons and strings 2026-07-19 19:48:27 -07:00
Travis Abendshien dc4251ff55 chore: bump version to v9.6.2 2026-07-19 19:19:29 -07:00
purpletennisball 174262b9b3 feat(thumbs): render .ai thumbnails (#1453) 2026-07-19 19:16:39 -07:00
Xarvex 27d761731c fix(ci): setup Ruff once, invoke manually
The parallel steps can have a race condition if they both have a lock on
the final Ruff installation path.

Initially, the action was reused for formatting and linting as the
ruff-action sets the necessary environment variables and matchers,
responsible for the annotations on the GitHub UI. Though, as it turns
out, this is already done whenever Ruff is installed, there is no
"special magic" whenever execution of the process happens.

So, only use the action for setup once, and in the parallel steps we can
invoke Ruff ourselves.
2026-07-19 16:11:40 -05:00
Jann Stute 51a9c16f50 refactor: remove dead folders table (#1444)
* refactor: remove dead folders table

* fix: remove folder param from all uses of Entry constructor

* fix: missing filename column after migration

* fix: bump db version to 300, since it is a breaking change

* fix: add empty library fixture for version 202
2026-07-17 17:35:08 -07:00
Travis Abendshien 6aa0cf74f9 chore: removed unused collage_icon.py file 2026-07-12 21:08:31 -07:00
Travis Abendshien 49b450c3a4 fix: don't prevent greater minor version DBs from opening 2026-07-12 20:58:53 -07:00
Ludvig Sandh a1dfa62e4a fix: correctly display duration statistic in preview panel (#1421)
* fix: correctly display duration statistic in preview panel

* style: format file_attributes.py with ruff

* refactor: consolidate preview thumb current file state

* refactor: remove another redundant current file state

* refactor: move generic format_duration function to string formatting util file

* refactor: tighten signal's declared types
2026-07-12 13:16:14 -07:00
Xarvex aa2d9d4815 fix(ci): misapplied rebase, cont 9d5200b
fixes: #1445 (for good)
2026-07-11 19:33:29 -05:00
Xarvex 16cfa8d2ff chore(ci): ignore translations pushes for reuse 2026-07-11 19:27:21 -05:00
Xarvex 9d5200b2f2 fix(ci): path matching fixes, corrected concurrency
Pull requests are now correctly handled and concurrency is per-job.
translations branch can safely be ignored for pushes.
2026-07-11 19:19:42 -05:00
Jann Stute f252a86fd5 refactor: split out sql migrations from library (#1432)
* refactor: minor simplification

* refactor: split open_library into new and not new case

Functionality is entirely unchanged, but entire method is duplicated.
Removing dead code from either copy is next.

* refactor: remove dead code

* doc: add todos and remove trivially true assert

* refactor: add assurance 1 version check

Assurance 1 was introduced with library version 101, specifically commmit 12e074b71d.
Thus all libraries created on version 101 and above already fullfill it and don't need it.
Furthermore, it only fails if the library didn't need the assurance, meaning the try-except and warning catching can be removed

* refactor: remove various unnecessary try-except statements in new_lib

All of these were introduced to account for the differences between new libraries and existing ones,
which makes them unnecessary after this split.

* refactor: remove unnecessary check in new_lib

* refactor: move folder assurance after migrations

The folder assurance has been present since the very first SQL commit e5e7b8afc6,
and thus only has an effect when the library is moved, meaning that is semantically not part of the migrations.

* refactor: massively simplify open_library

* refactor: move engine creation to static method

* refactor: add version check for assurance 3

Assurance 3 was introduced in commit 47c3d5338f
shortly (24h 20min) after the DB version had been bumped to 200.
Since it was also included in the first release with DB version 200,
all libraries created on DB version 200 and above
can be presumed to have already had this applied on creation.

* refactor: add assurance 3 to DB 200 migration

* refactor: move assurance 1 to a proper migration method

Moving the assurance after the migrations 7, 8, 9, and 100 is fine because it doesn't affect those.

* refactor: update version after every successfull migration

Since every migration migrates the library to a certain DB_VERSION, we can simply update the library version after such a successfull migration.
This way if a migration fails, the previous migrations won't be rerun the next time the library is opened.

* refactor: rewrite migration procedure as loop

* refactor: apply migration and update version in same transaction

None of the content of the migrations is changed, only the `with session:` statements are removed.

* refactor: replace all commits in the migrations with flushes

Also removes various try-except statements in the migrations.
These were introduced to catch the case where the migration is run twice due to a later migration failing,
this is not necessary anymore as every migration will only be executed at most once per library.

* refactor: make sure the migration log statements are consistent

* fix: pass library dir to migrations

* fix(db migration 8): only add colors that are actually new

DB Migration 8 was previously adding all colors except for the neon ones,
however, all but three of those have been around since DB version 4,
meaning that they don't need to be added at all (which caused the tests to fail).

* fix: json migration used outdated interface

* fix(open_library): create TS directory only if not opened in memory

* fix: enable sane transaction behaviour

By default in SQLAlchemy schema changes are automatically committed,
even if auto-commit is turned off.
This commit turns off auto-commit completely,
which caused some schema to not be applied correctly,
but those were fixed here as well.

* refactor: hide 'argument is not accessed' notices

* fix(db migration 9): filename property wasn't written correctly

* fix(db migration 104): include/exclude list was loaded incorrectly

* feat: log start and end of DB migrations

* fix: don't use double transaction in open_sqlite_library

* fix: only commit once when creating new library

* doc: add comment on removing Folder logic

* refactor: remove dunder naming
2026-07-11 12:36:55 -07:00
Xarvex a0fb679729 fix(ci): more path matching 2026-07-11 01:29:11 -05:00
Xarvex b182b2ff7e chore(ci): more paths for pytest to trigger on 2026-07-09 22:23:17 -05:00
purpletennisball 308b36b31e fix: thumbnail rendering for older pxd files (#1441) 2026-07-09 18:03:47 -07:00
57 changed files with 1904 additions and 1813 deletions
+75 -16
View File
@@ -8,26 +8,29 @@ on:
paths: &on_paths
- .github/actions/setup-python/action.yml
- .github/workflows/checks_python.yml
- src/**/resources/**
- src/**/*.json
- src/**/*.qrc
- tests/**
- .editorconfig
- pyproject.toml
- uv.lock
- '**.py'
- '**.pyi'
- 'src/tagstudio/resources/**'
- '**.pyi?'
push:
branches-ignore:
- translations
paths: *on_paths
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: sh
jobs:
run-conditions:
permissions:
pull-requests: read
name: Run Conditions
runs-on: ubuntu-latest
outputs:
@@ -36,14 +39,25 @@ jobs:
ruff: ${{ steps.run-conditions.outputs.ruff }}
steps:
- name: Checkout repository
if: github.event_name != 'pull_request'
uses: actions/checkout@v7
with:
fetch-depth: 0
# Largest positive number; infinite depth.
# Using 0 would grab all branches.
# See: https://github.com/actions/checkout/issues/520
# See: https://stackoverflow.com/questions/6802145/how-to-convert-a-git-shallow-clone-to-a-full-clone/6802238#6802238
# `git fetch --unshallow` as suggested in later answers would be an extra operation.
fetch-depth: '2147483647'
- name: Check changed files
id: changed-files
uses: tj-actions/changed-files@v47.0.6
with:
fail_on_initial_diff_error: 'true'
fail_on_submodule_diff_error: 'true'
skip_initial_fetch: 'true'
# WARNING: Does not support `?` glob operand!
files_yaml: |
generic:
- .github/workflows/checks_python.yml
@@ -55,20 +69,51 @@ jobs:
- .github/actions/setup-python/action.yml
pytest:
- .github/actions/setup-python/action.yml
- 'src/tagstudio/resources/**'
- src/**/resources/**
- src/**/*.json
- src/**/*.qrc
- tests/**
ruff:
- .editorconfig
- name: Set run conditions
id: run-conditions
env:
CHANGED_GENERIC: ${{ steps.changed-files.outputs.generic_any_changed }}
CHANGED_PYRIGHT: ${{ steps.changed-files.outputs.pyright_any_changed }}
CHANGED_PYTEST: ${{ steps.changed-files.outputs.pytest_any_changed }}
CHANGED_RUFF: ${{ steps.changed-files.outputs.ruff_any_changed }}
run: |
pyright=false
pytest=false
ruff=false
if [ "${CHANGED_GENERIC}" = true ]; then
pyright=true
pytest=true
ruff=true
else
if [ "${CHANGED_PYRIGHT}" = true ]; then
pyright=true
fi
if [ "${CHANGED_PYTEST}" = true ]; then
pytest=true
fi
if [ "${CHANGED_RUFF}" = true ]; then
ruff=true
fi
fi
cat <<EOF >>"${GITHUB_OUTPUT}"
pyright=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.pyright_any_changed }}
pytest=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.pytest_any_changed }}
ruff=${{ steps.changed-files.outputs.generic_any_changed || steps.changed-files.outputs.ruff_any_changed }}
pyright=${pyright}
pytest=${pytest}
ruff=${ruff}
EOF
check-pyright:
concurrency:
group: pyright-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
name: Pyright
needs: run-conditions
if: needs.run-conditions.outputs.pyright == 'true'
@@ -89,6 +134,9 @@ jobs:
run: pyright
check-pytest:
concurrency:
group: ${{ matrix.os }}-pytest-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
strategy:
fail-fast: false
matrix:
@@ -151,6 +199,10 @@ jobs:
run: pytest
check-ruff:
concurrency:
group: ruff-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
name: Ruff
needs: run-conditions
if: needs.run-conditions.outputs.ruff == 'true'
@@ -158,11 +210,18 @@ jobs:
steps:
- *checkout
- name: Setup Ruff
uses: astral-sh/ruff-action@v4.0.0
with:
# No-op operation, since executing Ruff cannot be disabled for the action.
# Note that `--version` has different behavior than `version`, as the
# latter will fail if passed any extra args, even if that arg is empty.
args: --version
src: ''
- parallel:
- name: Run Ruff linter
uses: astral-sh/ruff-action@v4.0.0
run: ruff check
- name: Run Ruff formatter
uses: astral-sh/ruff-action@v4.0.0
with:
args: format --check
run: ruff format --check
+6 -1
View File
@@ -3,7 +3,12 @@
---
name: REUSE Compliance Check
on: [pull_request, push]
on:
pull_request:
push:
branches-ignore:
- translations
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+1 -1
View File
@@ -8,7 +8,7 @@ path = [
"docs/CNAME",
"docs/assets/**",
"src/tagstudio/qt/resources.json",
"src/tagstudio/resources/icon.*",
"src/tagstudio/resources/icon*.*",
"src/tagstudio/resources/tagstudio.desktop",
"src/tagstudio/resources/templates/ts_ignore_template.txt",
"src/tagstudio/resources/templates/ts_ignore_template_blank.txt",
+1 -1
View File
@@ -118,7 +118,7 @@ If you choose to manually set up a virtual environment and install dependencies
!!! Warning "Linux Library Dependencies"
If developing TagStudio on Linux, certain libraries are required that may not be included with your distribution. A full list of these can be found [here](install.md#linux).
## Nix(OS)
## Nix & NixOS
If using [Nix](https://nixos.org/), there is a development environment already provided in the [flake](https://wiki.nixos.org/wiki/Flakes) that is accessible with the following command:
+2 -2
View File
@@ -27,12 +27,12 @@ hide:
![TagStudio screenshot](./assets/tag_bubbles.png)
**TagStudio** is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
<span style="font-family: Bai Jamjuree, Roboto, sans-serif; font-size: 1.1rem; letter-spacing: -0.05rem;"><span style="font-weight: 900;">Tag</span><span style="font-weight: 500;"><i>Studio</i></span></span> is a photo & file organization application with an underlying tag-based system that focuses on giving freedom and flexibility to the user. No proprietary programs or formats, no sea of sidecar files, and no complete upheaval of your filesystem structure.
</div>
<figure markdown="span">
[:material-download: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
[:material-github: Download Latest Release](https://github.com/TagStudioDev/TagStudio/releases){ .md-button .md-button--primary }
</figure>
## :material-star: Core Features
+3 -3
View File
@@ -95,9 +95,9 @@ Some external dependencies are required for TagStudio to execute. Below is a tab
Aborted (core dumped)
```
### :material-nix: Nix(OS)
### :material-nix: Nix & NixOS
For [Nix(OS)](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
For [Nix](https://nixos.org/), the TagStudio repository includes a [flake](https://wiki.nixos.org/wiki/Flakes) that provides some outputs such as a development shell and package.
Two packages are provided: `tagstudio` and `tagstudio-jxl`. The distinction was made because `tagstudio-jxl` has an extra compilation step for [JPEG-XL](https://jpeg.org/jpegxl) image support. To give either of them a test run, you can execute `nix run github:TagStudioDev/TagStudio#tagstudio`. If you are in a cloned repository and wish to run a package with the context of the repository, you can simply use `nix run` with no arguments.
@@ -256,4 +256,4 @@ To generate thumbnails for RAR-based files (like `.cbr`) you'll need an extracto
### ripgrep
A recommended tool to improve the performance of directory scanning is [`ripgrep`](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
A recommended tool to improve the performance of directory scanning is [ripgrep](https://github.com/BurntSushi/ripgrep), a Rust-based directory walker that natively integrates with our [`.ts_ignore`](ignore.md) (`.gitignore`-style) pattern matching system for excluding files and directories. Ripgrep is already pre-installed on some Linux distributions and also available from several package managers.
+69 -2
View File
@@ -3,11 +3,14 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
/* Dark Theme */
[data-md-color-scheme="slate"] {
--md-primary-fg-color: rgb(197, 110, 255);
--md-accent-fg-color: rgb(92, 222, 255);
--md-default-bg-color: #060617;
--md-default-fg-color: #eae1ff;
--md-default-fg-color--light: #b898ff;
--md-code-fg-color: #eae1ffcc;
--md-default-fg-color--light: #c2a5ff;
--md-code-fg-color: #d8c7ffcc;
--md-code-hl-string-color: rgb(92, 255, 228);
--md-code-hl-keyword-color: rgb(61, 155, 255);
--md-code-hl-constant-color: rgb(205, 78, 255);
@@ -17,6 +20,8 @@
/* Light Theme */
[data-md-color-scheme="default"] {
--md-primary-fg-color: #7758ff;
--md-accent-fg-color: rgb(22, 166, 255);
--md-default-fg-color--light: #090a26;
}
@@ -73,6 +78,11 @@ td {
padding: 0.5em 1em 0.5em 1em !important;
}
hr {
border-bottom-width: 2px !important;
border-color: #9988ff50 !important;
}
.md-typeset ul li ul {
margin-top: 0;
margin-bottom: 0.1rem;
@@ -119,6 +129,18 @@ h2,
margin-right: -0.8rem;
}
.md-code__nav,
.md-content__button,
.headerlink {
border-radius: 0.2rem;
background: none;
color: #9988ff50 !important;
}
.md-code__nav:hover {
background-color: #9988ff50;
}
figcaption {
margin-top: 0 !important;
}
@@ -149,6 +171,51 @@ td code {
padding: 0 !important;
}
.admonition {
border-radius: 0.4rem !important;
border-width: 2px !important;
}
.highlight > .filename {
border-width: 2px !important;
border: solid;
border-color: #9988ff10 !important;
border-radius: 0.4rem 0.4rem 0 0 !important;
}
code {
border-radius: 0.4rem !important;
border-width: 2px !important;
border: solid;
border-color: #9988ff10;
}
:is(span, ul, li, td, p, a) > code {
border-width: 1px !important;
border-radius: 0.1rem !important;
}
.filename + pre > code {
border-top-width: 0 !important;
border-radius: 0 0 0.4rem 0.4rem !important;
}
.tabbed-labels.tabbed-labels--linked {
padding-left: 0.4rem;
padding-right: 0.4rem;
box-shadow: none !important;
}
.admonition-title {
font-size: 0.7rem;
font-family: "Bai Jamjuree", Roboto, sans-serif;
font-weight: 600 !important;
}
.admonition-title span {
margin-top: 0.05rem !important;
}
/* Matches the palette used by mkdocs-material */
.priority-high {
color: #f1185a;
+27
View File
@@ -29,3 +29,30 @@ h2 {
margin-right: 0;
}
}
.grid.cards > ul > li {
border-radius: 0.4rem !important;
border-width: 2px !important;
border-color: #9988ff20 !important;
}
.md-button--primary {
margin: 1rem;
padding: 0.3rem 1.2rem !important;
border-radius: 0.4rem !important;
font-size: 1rem;
font-family: "Bai Jamjuree", Roboto, sans-serif;
background: linear-gradient(60deg, rgb(205, 78, 255) 0%, rgb(116, 123, 255) 100%);
border-style: solid;
border-width: 0 0 2px 0 !important;
border-color: #ffffff33 !important;
}
.md-button--primary:hover {
background: linear-gradient(60deg, rgb(205, 78, 255) 30%, rgb(116, 123, 255) 100%);
border-color: #ffffff55 !important;
}
.md-button--primary span {
margin-top: 0.1rem !important;
}
+4 -4
View File
@@ -70,16 +70,16 @@ theme:
# Palette toggle for light mode
- media: "(prefers-color-scheme: light)"
scheme: default
primary: purple
accent: purple
primary: custom
accent: custom
toggle:
icon: material/lightbulb
name: Switch to Dark Mode
# Palette toggle for dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: purple
accent: purple
primary: custom
accent: custom
toggle:
icon: material/lightbulb-night-outline
name: Switch to System Preference
+3 -3
View File
@@ -9,7 +9,7 @@ build-backend = "hatchling.build"
[project]
name = "TagStudio"
description = "A User-Focused Photo & File Management System."
version = "9.6.1"
version = "9.6.2"
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.12,<3.14"
@@ -88,7 +88,7 @@ filterwarnings = [
ignore = [
".venv/**",
"src/tagstudio/core/library/json/",
"src/tagstudio/qt/previews/vendored/pydub/",
"src/tagstudio/renderers/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
@@ -117,7 +117,7 @@ ignore = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["D", "E402"]
"src/tagstudio/qt/previews/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
"src/tagstudio/renderers/vendored/**" = ["B", "E", "N", "UP", "SIM115"]
[tool.ruff.lint.pydocstyle]
convention = "google"
+2 -2
View File
@@ -3,8 +3,8 @@
from importlib.metadata import version
VERSION: str = version("tagstudio") # Major.Minor.Patch
VERSION_BRANCH: str = "" # Usually "" or "Pre-Release"
VERSION: str = version("tagstudio")
BUILD_TYPE: str = "" # Usually "", "app.nightly", or "app.pre_release"
COPYRIGHT_YEARS: str = "2021-2026"
COPYRIGHT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien & TagStudio Contributors"
COPYRIGHT_COMPACT: str = f"© {COPYRIGHT_YEARS} Travis Abendshien\n& TagStudio Contributors"
@@ -9,7 +9,7 @@ JSON_FILENAME: str = "ts_library.json"
DB_VERSION_CURRENT_KEY: str = "CURRENT"
DB_VERSION_INITIAL_KEY: str = "INITIAL"
DB_VERSION: int = 202
DB_VERSION: int = 300
TAG_CHILDREN_QUERY = text("""
WITH RECURSIVE ChildTags AS (
+10 -6
View File
@@ -42,13 +42,17 @@ def make_engine(connection_string: str) -> Engine:
def make_tables(engine: Engine) -> None:
logger.info("[Library] Creating DB tables...")
Base.metadata.create_all(engine)
# tag IDs < 1000 are reserved
# create tag and delete it to bump the autoincrement sequence
# TODO - find a better way
# is this the better way?
with engine.connect() as conn:
# TODO: this should instead be migrations that create the exact tables that were added in
# the respective DB versions
Base.metadata.create_all(conn)
conn.commit()
# TODO: this needs to be a migration
# tag IDs < 1000 are reserved
# create tag and delete it to bump the autoincrement sequence
# TODO - find a better way
# is this the better way?
result = conn.execute(text("SELECT SEQ FROM sqlite_sequence WHERE name='tags'"))
autoincrement_val = result.scalar()
if not autoincrement_val or autoincrement_val <= RESERVED_TAG_END:
File diff suppressed because it is too large Load Diff
@@ -182,23 +182,11 @@ class Tag(Base):
return self.name >= other.name
class Folder(Base):
__tablename__ = "folders"
# TODO - implement this
id: Mapped[int] = mapped_column(primary_key=True)
path: Mapped[Path] = mapped_column(PathType, unique=True)
uuid: Mapped[str] = mapped_column(unique=True)
class Entry(Base):
__tablename__ = "entries"
id: Mapped[int] = mapped_column(primary_key=True)
folder_id: Mapped[int] = mapped_column(ForeignKey("folders.id"))
folder: Mapped[Folder] = relationship("Folder")
path: Mapped[Path] = mapped_column(PathType, unique=True)
filename: Mapped[str] = mapped_column()
suffix: Mapped[str] = mapped_column()
@@ -235,7 +223,6 @@ class Entry(Base):
def __init__(
self,
path: Path,
folder: Folder,
fields: list[BaseField],
id: int | None = None,
date_created: dt | None = None,
@@ -244,7 +231,6 @@ class Entry(Base):
) -> None:
super().__init__()
self.path = path
self.folder = folder
self.id = id # pyright: ignore[reportAttributeAccessIssue]
self.filename = path.name
self.suffix = path.suffix.lstrip(".").lower()
-2
View File
@@ -16,7 +16,6 @@ from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.ignore import PATH_GLOB_FLAGS, Ignore, ignore_to_glob
from tagstudio.core.utils.silent_subprocess import silent_run # pyright: ignore
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
@@ -41,7 +40,6 @@ class RefreshTracker:
entries = [
Entry(
path=entry_path,
folder=unwrap(self.library.folder),
fields=[],
date_added=dt.now(),
)
+3 -1
View File
@@ -105,6 +105,7 @@ class MediaCategories:
# These sets are used either individually or together to form the final sets
# for the MediaCategory(s).
# These sets may be combined and are NOT 1:1 with the final categories.
_ADOBE_ILLUSTRATOR_SET: set[str] = {".ai"}
_ADOBE_PHOTOSHOP_SET: set[str] = {
".pdd",
".psb",
@@ -304,6 +305,7 @@ class MediaCategories:
".nef",
".nrw",
".orf",
".r3d",
".raf",
".raw",
".rw2",
@@ -580,7 +582,7 @@ class MediaCategories:
)
PDF_TYPES = MediaCategory(
media_type=MediaType.PDF,
extensions=_PDF_SET,
extensions=_PDF_SET | _ADOBE_ILLUSTRATOR_SET,
is_iana=False,
name="pdf",
)
@@ -49,3 +49,14 @@ def is_version_outdated(current: str, latest: str) -> bool:
return vcur.patch < vlat.patch
else:
return vcur.prerelease is not None or vcur.build is not None
def format_duration(duration: int | float) -> str:
"""Format a duration in seconds as M:SS or H:MM:SS."""
try:
seconds = int(float(duration))
hours, seconds = divmod(seconds, 3600)
minutes, seconds = divmod(seconds, 60)
return f"{hours}:{minutes:02}:{seconds:02}" if hours else f"{minutes}:{seconds:02}"
except (OverflowError, ValueError):
return "-:--"
+3 -2
View File
@@ -10,7 +10,8 @@ import traceback
import structlog
from tagstudio.core.constants import VERSION, VERSION_BRANCH
from tagstudio.core.constants import BUILD_TYPE, VERSION
from tagstudio.qt.translations import Translations
from tagstudio.qt.ts_qt import QtDriver
logger = structlog.get_logger(__name__)
@@ -59,7 +60,7 @@ def main():
"--version",
action="version",
help="Displays TagStudio version information.",
version=f"TagStudio v{VERSION} {VERSION_BRANCH}",
version=f"TagStudio v{VERSION} {Translations[BUILD_TYPE] if BUILD_TYPE else ''}",
)
args = parser.parse_args()
@@ -32,8 +32,6 @@ Image.MAX_IMAGE_PIXELS = None
class PreviewThumb(PreviewThumbView):
__current_file: Path
def __init__(self, library: Library, driver: "QtDriver"):
super().__init__(library, driver)
@@ -114,7 +112,7 @@ class PreviewThumb(PreviewThumbView):
def display_file(self, filepath: Path) -> FileAttributeData:
"""Render a single file preview."""
self.__current_file = filepath
self._current_file = filepath
ext = filepath.suffix.lower()
@@ -150,21 +148,26 @@ class PreviewThumb(PreviewThumbView):
@override
def _open_file_action_callback(self):
open_file(
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
)
if self._current_file:
open_file(
self._current_file,
windows_start_command=self.__driver.settings.windows_start_command,
)
@override
def _open_explorer_action_callback(self):
open_file(self.__current_file, file_manager=True)
if self._current_file:
open_file(self._current_file, file_manager=True)
@override
def _delete_action_callback(self):
if bool(self.__current_file):
self.__driver.delete_files_callback(self.__current_file)
if self._current_file:
self.__driver.delete_files_callback(self._current_file)
@override
def _button_wrapper_callback(self):
open_file(
self.__current_file, windows_start_command=self.__driver.settings.windows_start_command
)
if self._current_file:
open_file(
self._current_file,
windows_start_command=self.__driver.settings.windows_start_command,
)
+2 -6
View File
@@ -6,7 +6,7 @@ from pathlib import Path
import ffmpeg
from tagstudio.qt.previews.vendored.probe import probe
from tagstudio.renderers.vendored.probe import probe
def is_readable_video(filepath: Path | str):
@@ -23,11 +23,7 @@ def is_readable_video(filepath: Path | str):
return False
for stream in result["streams"]:
# DRM check
if stream.get("codec_tag_string") in [
"drma",
"drms",
"drmi",
]:
if stream.get("codec_tag_string") in ["drma", "drms", "drmi"]:
return False
except ffmpeg.Error:
return False
+8 -4
View File
@@ -19,12 +19,12 @@ from PySide6.QtWidgets import (
)
from tagstudio.core.constants import (
BUILD_TYPE,
COPYRIGHT,
DISCORD_URL,
DOCS_URL,
GITHUB_REPO_URL,
VERSION,
VERSION_BRANCH,
)
from tagstudio.core.ts_core import TagStudioCore
from tagstudio.core.utils.ffmpeg_status import FfmpegStatus, FfprobeStatus
@@ -42,7 +42,12 @@ from tagstudio.qt.views.stylesheets.stylesheets import form_content_style, heade
class AboutModal(QWidget):
"""Modal window showing information about the TagStudio application."""
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
VERSION_STR: str = " ".join(
[
f"{Translations['about.version']}",
f"{VERSION} {(' (' + Translations[BUILD_TYPE] + ')') if BUILD_TYPE else ''}",
]
)
def __init__(self, config_path: Path | str):
super().__init__()
@@ -196,8 +201,7 @@ class AboutModal(QWidget):
# ripgrep Status
ripgrep_path_title = QLabel("ripgrep") # NOTE: Don't localize
ripgrep_path_content = ClickableLabel()
ripgrep_path_content.setText(f"{ripgrep_status}") # TODO: Pass in constructor after #1386
ripgrep_path_content = ClickableLabel(f"{ripgrep_status}")
ripgrep_location = RipgrepStatus.which()
if ripgrep_location:
ripgrep_path_content.clicked.connect(
-133
View File
@@ -1,133 +0,0 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from pathlib import Path
import cv2
import structlog
from PIL import Image, ImageChops, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import QObject, Signal
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.helpers.file_tester import is_readable_video
logger = structlog.get_logger(__name__)
class CollageIconRenderer(QObject):
rendered = Signal(Image.Image)
done = Signal()
def __init__(self, library: Library):
QObject.__init__(self)
self.lib = library
def render(
self,
entry_id: int,
size: tuple[int, int],
data_tint_mode: bool,
data_only_mode: bool,
keep_aspect: bool,
):
entry = unwrap(self.lib.get_entry(entry_id))
filepath = unwrap(self.lib.library_dir) / entry.path
color: str = ""
try:
if data_tint_mode or data_only_mode:
color = "#28bb48" if entry.tags else "#e22c3c"
if data_only_mode:
pic = Image.new("RGB", size, color)
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
if not data_only_mode:
logger.info(
"Combining icons",
entry=entry,
color=self.get_file_color(filepath.suffix.lower()),
)
ext: str = filepath.suffix.lower()
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
try:
with Image.open(filepath) as pic:
if keep_aspect:
pic.thumbnail(size)
else:
pic = pic.resize(size)
if data_tint_mode and color:
pic = pic.convert(mode="RGB")
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
self.rendered.emit(pic)
except DecompressionBombError as e:
logger.info(f"[ERROR] One of the images was too big ({e})")
elif MediaCategories.is_ext_in_category(
ext, MediaCategories.VIDEO_TYPES
) and is_readable_video(filepath):
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
success, frame = video.read()
# NOTE: 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.
max_frame_seek: int = 10
for i in range(
0,
min(
max_frame_seek,
math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT)),
),
):
success, frame = video.read()
if not success:
video.set(cv2.CAP_PROP_POS_FRAMES, i)
else:
break
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
with Image.fromarray(frame, mode="RGB") as pic:
if keep_aspect:
pic.thumbnail(size)
else:
pic = pic.resize(size)
if data_tint_mode and color:
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
self.rendered.emit(pic)
except (UnidentifiedImageError, FileNotFoundError):
logger.error("Couldn't read entry", entry=entry.path)
with Image.open(
str(Path(__file__).parents[1] / "resources/qt/images/thumb_broken_512.png")
) as pic:
pic.thumbnail(size)
if data_tint_mode and color:
pic = pic.convert(mode="RGB")
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
# collage.paste(pic, (y*thumb_size, x*thumb_size))
self.rendered.emit(pic)
except KeyboardInterrupt:
logger.info("Collage operation cancelled.")
except Exception:
logger.exception("render failed", entry=entry.path)
self.done.emit()
def get_file_color(self, ext: str):
if ext.lower() == "gif":
return "\033[93m"
if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES):
return "\033[37m"
elif MediaCategories.is_ext_in_category(ext, MediaCategories.VIDEO_TYPES):
return "\033[96m"
elif MediaCategories.is_ext_in_category(ext, MediaCategories.PLAINTEXT_TYPES):
return "\033[92m"
else:
return "\033[97m"
+2 -10
View File
@@ -7,7 +7,6 @@ import platform
import typing
from dataclasses import dataclass
from datetime import datetime as dt
from datetime import timedelta
from pathlib import Path
import structlog
@@ -20,6 +19,7 @@ from tagstudio.core.enums import ShowFilepathOption
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.ignore import Ignore
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.str_formatting import format_duration
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color
from tagstudio.qt.translations import Translations
@@ -224,15 +224,7 @@ class FileAttributes(QWidget):
if stats.duration is not None:
stats_label_text = add_newline(stats_label_text)
try:
dur_str = str(timedelta(seconds=float(stats.duration)))[:-7]
if dur_str.startswith("0:"):
dur_str = dur_str[2:]
if dur_str.startswith("0"):
dur_str = dur_str[1:]
except OverflowError:
dur_str = "-:--"
stats_label_text += f"{dur_str}"
stats_label_text += format_duration(stats.duration)
if font_family:
stats_label_text = add_newline(stats_label_text)
+6 -5
View File
@@ -395,14 +395,15 @@ class JsonMigrationModal(QObject):
# Convert JSON Library to SQLite
yield Translations["json_migration.creating_database_tables"]
self.sql_lib = SqliteLibrary()
self.temp_path: Path = (
self.json_lib.library_dir / TS_FOLDER_NAME / "migration_ts_library.sqlite"
)
temp_filename = "migration_ts_library.sqlite"
self.temp_path: Path = self.json_lib.library_dir / TS_FOLDER_NAME / temp_filename
if self.temp_path.exists():
logger.info('Temporary migration file "temp_path" already exists. Removing...')
self.temp_path.unlink()
self.sql_lib.open_sqlite_library(
self.json_lib.library_dir, is_new=True, storage_path=str(self.temp_path)
self.sql_lib.create_sqlite_library(
self.json_lib.library_dir,
in_memory=False,
sql_filename=temp_filename,
)
yield Translations.format(
"json_migration.migrating_files_entries", entries=len(self.json_lib.entries)
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -40,7 +40,7 @@ from PySide6.QtGui import (
from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea
import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport]
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE, VERSION, VERSION_BRANCH
from tagstudio.core.constants import BUILD_TYPE, TAG_ARCHIVED, TAG_FAVORITE, VERSION
from tagstudio.core.driver import DriverMixin
from tagstudio.core.enums import AppCacheItems, MacroID, ShowFilepathOption
from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum
@@ -203,7 +203,7 @@ class QtDriver(DriverMixin, QObject):
self.scrollbar_pos = 0
self.spacing = None
self.branch: str = (" (" + VERSION_BRANCH + ")") if VERSION_BRANCH else ""
self.branch: str = (" (" + Translations[BUILD_TYPE] + ")") if BUILD_TYPE else ""
self.base_title: str = f"TagStudio Alpha {VERSION}{self.branch}"
# self.title_text: str = self.base_title
# self.buffer = {}
@@ -51,6 +51,7 @@ class PreviewPanelView(QWidget):
self._containers = FieldContainers(
self.lib, driver
) # TODO: this should be name mangled, but is still needed on the controller side atm
self.__current_stats: FileAttributeData | None = None
preview_section = QWidget()
preview_layout = QVBoxLayout(preview_section)
@@ -132,6 +133,7 @@ class PreviewPanelView(QWidget):
def __connect_callbacks(self) -> None:
self.__add_field_button.clicked.connect(self._add_field_button_callback)
self.__add_tag_button.clicked.connect(self._add_tag_button_callback)
self._thumb.stats_updated.connect(self.__thumb_stats_updated_callback)
def _add_field_button_callback(self) -> None:
raise NotImplementedError()
@@ -139,6 +141,25 @@ class PreviewPanelView(QWidget):
def _add_tag_button_callback(self) -> None:
raise NotImplementedError()
def __thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None:
if len(self._selected) != 1:
return
if filepath != self._thumb.current_file:
return
if self.__current_stats is None:
self.__current_stats = FileAttributeData()
if stats.width is not None:
self.__current_stats.width = stats.width
if stats.height is not None:
self.__current_stats.height = stats.height
if stats.duration is not None:
self.__current_stats.duration = stats.duration
self._file_attrs.update_stats(filepath, self.__current_stats)
def _set_selection_callback(self) -> None:
raise NotImplementedError()
@@ -155,6 +176,7 @@ class PreviewPanelView(QWidget):
# No Items Selected
if len(selected) == 0:
self._thumb.hide_preview()
self.__current_stats = None
self._file_attrs.update_stats()
self._file_attrs.update_date_label()
self._containers.hide_containers()
@@ -167,9 +189,12 @@ class PreviewPanelView(QWidget):
entry: Entry = unwrap(self.lib.get_entry(entry_id))
filepath: Path = unwrap(self.lib.library_dir) / entry.path
if filepath != self._thumb.current_file:
self.__current_stats = None
if update_preview:
stats: FileAttributeData = self._thumb.display_file(filepath)
self.__current_stats = stats
self._file_attrs.update_stats(filepath, stats)
self._file_attrs.update_date_label(filepath)
self._containers.update_from_entry(entry_id)
@@ -182,6 +207,7 @@ class PreviewPanelView(QWidget):
elif len(selected) > 1:
# items: list[Entry] = [self.lib.get_entry_full(x) for x in self.driver.selected]
self._thumb.hide_preview() # TODO: Render mixed selection
self.__current_stats = None
self._file_attrs.update_multi_selection(len(selected))
self._file_attrs.update_date_label()
self._containers.hide_containers() # TODO: Allow for mixed editing
+43 -13
View File
@@ -34,11 +34,13 @@ class PreviewThumbView(QWidget):
"""The Preview Panel Widget."""
check_ffmpeg = Signal(bool)
stats_updated = Signal(Path, FileAttributeData)
__img_button_size: tuple[int, int]
__image_ratio: float
__filepath: Path | None
_current_file: Path | None
__should_render_on_resize: bool
__rendered_res: tuple[int, int]
def __init__(self, library: Library, driver: "QtDriver") -> None:
@@ -47,6 +49,8 @@ class PreviewThumbView(QWidget):
self.__img_button_size = (266, 266)
self.__image_ratio = 1.0
self.__should_render_on_resize = False
self.__image_layout = QStackedLayout(self)
self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
@@ -92,6 +96,10 @@ class PreviewThumbView(QWidget):
self.__media_player.addAction(open_file_action)
self.__media_player.addAction(open_explorer_action)
self.__media_player.addAction(delete_action)
# QMediaPlayer loads duration asynchronously after setSource().
self.__media_player.player.durationChanged.connect(
self.__media_player_duration_changed_callback
)
# Need to watch for this to resize the player appropriately.
self.__media_player.player.hasVideoChanged.connect(
@@ -128,6 +136,16 @@ class PreviewThumbView(QWidget):
def __media_player_video_changed_callback(self, video: bool) -> None:
self.__update_image_size((self.size().width(), self.size().height()))
def __media_player_duration_changed_callback(self, duration_ms: int) -> None:
filepath = self.__media_player.filepath
if filepath is None or duration_ms <= 0:
return
self.stats_updated.emit(
filepath,
FileAttributeData(duration=duration_ms // 1000),
)
def __thumb_renderer_updated_callback(
self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
) -> None:
@@ -207,7 +225,8 @@ class PreviewThumbView(QWidget):
self.__preview_gif.hide()
def __render_thumb(self, filepath: Path) -> None:
self.__filepath = filepath
self.__should_render_on_resize = True
self.__rendered_res = (
math.ceil(self.__img_button_size[0] * THUMB_SIZE_FACTOR),
math.ceil(self.__img_button_size[1] * THUMB_SIZE_FACTOR),
@@ -221,17 +240,16 @@ class PreviewThumbView(QWidget):
update_on_ratio_change=True,
)
def __update_media_player(self, filepath: Path) -> int:
"""Display either audio or video.
Returns the duration of the audio / video.
"""
def __update_media_player(self, filepath: Path) -> None:
"""Display either audio or video."""
self.__media_player.play(filepath)
return self.__media_player.player.duration() * 1000
def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
self.__should_render_on_resize = False
self.__switch_preview(MediaType.VIDEO)
stats = FileAttributeData(duration=self.__update_media_player(filepath))
self.__update_media_player(filepath)
stats = FileAttributeData()
if size is not None:
stats.width = size.width()
@@ -250,10 +268,13 @@ class PreviewThumbView(QWidget):
def _display_audio(self, filepath: Path) -> FileAttributeData:
self.__switch_preview(MediaType.AUDIO)
self.__render_thumb(filepath)
return FileAttributeData(duration=self.__update_media_player(filepath))
self.__update_media_player(filepath)
return FileAttributeData()
def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None:
"""Update the animated image preview from a filepath."""
self.__should_render_on_resize = False
stats = FileAttributeData()
# Ensure that any movie and buffer from previous animations are cleared.
@@ -296,17 +317,26 @@ class PreviewThumbView(QWidget):
def hide_preview(self) -> None:
"""Completely hide the file preview."""
self.__switch_preview(None)
self.__filepath = None
self._current_file = None
self.__should_render_on_resize = False
@override
def resizeEvent(self, event: QResizeEvent) -> None:
self.__update_image_size((self.size().width(), self.size().height()))
if self.__filepath is not None and self.__rendered_res < self.__img_button_size:
self.__render_thumb(self.__filepath)
if (
self._current_file is not None
and self.__should_render_on_resize
and self.__rendered_res < self.__img_button_size
):
self.__render_thumb(self._current_file)
return super().resizeEvent(event)
@property
def media_player(self) -> MediaPlayer:
return self.__media_player
@property
def current_file(self) -> Path | None:
return self._current_file
+7 -2
View File
@@ -10,7 +10,7 @@ from PySide6.QtCore import QRect, Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap
from PySide6.QtWidgets import QSplashScreen, QWidget
from tagstudio.core.constants import COPYRIGHT, COPYRIGHT_COMPACT, VERSION, VERSION_BRANCH
from tagstudio.core.constants import BUILD_TYPE, COPYRIGHT, COPYRIGHT_COMPACT, VERSION
from tagstudio.qt.global_settings import Splash
from tagstudio.qt.resource_manager import ResourceManager
from tagstudio.qt.translations import Translations
@@ -21,7 +21,12 @@ logger = structlog.get_logger(__name__)
class SplashScreen:
"""The custom splash screen widget for TagStudio."""
VERSION_STR: str = f"{Translations['about.version']} {VERSION} {(' (' + VERSION_BRANCH + ')') if VERSION_BRANCH else ''}" # noqa: E501
VERSION_STR: str = " ".join(
[
f"{Translations['about.version']}",
f"{VERSION} {(' (' + Translations[BUILD_TYPE] + ')') if BUILD_TYPE else ''}",
]
)
DEFAULT_SPLASH = Splash.AURORA
def __init__(
+158
View File
@@ -0,0 +1,158 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import tarfile
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Literal
import py7zr
import py7zr.io
import rarfile
import structlog
from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
type Archive = zipfile.ZipFile | rarfile.RarFile | SevenZipFile | TarFile
class SevenZipFile(py7zr.SevenZipFile):
"""Wrapper around py7zr.SevenZipFile to mimic zipfile.ZipFile's API."""
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
super().__init__(filepath, mode)
def read(self, name: str) -> bytes:
# SevenZipFile must be reset after every extraction
# See https://py7zr.readthedocs.io/en/stable/api.html#py7zr.SevenZipFile.extract
self.reset()
factory = py7zr.io.BytesIOFactory(limit=10485760) # 10 MiB
self.extract(targets=[name], factory=factory)
return factory.get(name).read()
class TarFile:
"""Wrapper around tarfile.TarFile to mimic zipfile.ZipFile's API."""
def __init__(self, filepath: Path, mode: Literal["r"]) -> None:
self.tar: tarfile.TarFile
self.filepath = filepath
self.mode: Literal["r"] = mode
def namelist(self) -> list[str]:
return self.tar.getnames()
def read(self, name: str) -> bytes:
return unwrap(self.tar.extractfile(name)).read()
def __enter__(self) -> "TarFile":
self.tar = tarfile.open(name=self.filepath, mode=self.mode).__enter__()
return self
def __exit__(self, *args) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
self.tar.__exit__(*args)
def open_archive(filepath: Path, ext: str = "") -> Archive:
"""Open an archive with its corresponding archiver.
Args:
filepath (Path): The path to the archive.
ext (str): The file extension.
Returns:
Archive: The opened archive.
"""
archiver: type[Archive] = zipfile.ZipFile
if ext in {".7z", ".cb7", ".s7z"}:
archiver = SevenZipFile
elif ext in {".cbr", ".rar"}:
archiver = rarfile.RarFile
elif ext in {".cbt", ".tar", ".tgz"}:
archiver = TarFile
return archiver(filepath, "r")
def first_image_in_archive(archive: Archive) -> Image.Image | None:
"""Find and extract the first renderable image in the archive.
Args:
archive (Archive): The current archive.
Returns:
Image: The first renderable image in the archive.
"""
for file_name in archive.namelist(): # pyright: ignore[reportUnknownVariableType]
ext = Path(file_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(file_name) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(image_data))
return None
def archive_thumb(
filepath: Path, ext: str = "", image_names: list[Path] | list[str] | None = None
) -> Image.Image | None:
"""Extract an embedded preview image from an archive.
Args:
filepath (Path): The path to the archive.
ext (str): The file extension. Used to help determine more specific archive type.
image_names: (list[Path] | list[str] | None): List of embedded image names to search for.
Returns:
Image: The first image found in the archive.
"""
try:
with open_archive(filepath, ext) as archive:
# If no list of image names to search for was provided, default to the first image.
if not image_names:
return first_image_in_archive(archive)
for image_name in image_names:
if image_name in archive.namelist():
file_data = archive.read(str(image_name)) # pyright: ignore[reportUnknownVariableType]
return image_from_bytes(BytesIO(file_data))
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return None
def apple_embedded_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render an apple embedded thumbnail (iWork, Apple Creative Studio)."""
image_names: list[str] = [
"preview.jpg",
"QuickLook/Preview.heic",
"QuickLook/Thumbnail.jpg",
"QuickLook/Thumbnail.heic",
"QuickLook/Thumbnail.webp",
"QuickLook/Icon.webp",
]
return archive_thumb(filepath, image_names=image_names)
def krita_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Krita file."""
image_names = ["preview.png"]
return archive_thumb(filepath, image_names=image_names)
def open_doc_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for an OpenDocument file."""
image_names = ["Thumbnails/thumbnail.png"]
return archive_thumb(filepath, image_names=image_names)
def powerpoint_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for a Microsoft PowerPoint file."""
image_names = ["docProps/thumbnail.jpeg"]
return archive_thumb(filepath, image_names=image_names)
+149
View File
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from io import BytesIO
from pathlib import Path
from warnings import catch_warnings
import numpy as np
import structlog
from mutagen import flac, id3, mp4
from mutagen._util import MutagenError
from PIL import Image, ImageDraw
from tagstudio.renderers.vendored.pydub.audio_segment import (
_AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
)
logger = structlog.get_logger(__name__)
def audio_album_thumb(filepath: Path, ext: str) -> Image.Image | None:
"""Return an album cover thumb from an audio file if a cover is present.
Args:
filepath (Path): The path of the file.
ext (str): The file extension (with leading ".").
"""
image: Image.Image | None = None
try:
if not filepath.is_file():
raise FileNotFoundError
artwork = None
if ext in [".mp3"]:
id3_tags: id3.ID3 = id3.ID3(filepath)
id3_covers: list = id3_tags.getall("APIC") # pyright: ignore[reportUnknownVariableType]
if id3_covers:
artwork = Image.open(BytesIO(id3_covers[0].data))
elif ext in [".flac"]:
flac_tags: flac.FLAC = flac.FLAC(filepath)
flac_covers: list = flac_tags.pictures # pyright: ignore[reportUnknownVariableType]
if flac_covers:
artwork = Image.open(BytesIO(flac_covers[0].data))
elif ext in [".mp4", ".m4a", ".aac"]:
mp4_tags: mp4.MP4 = mp4.MP4(filepath)
mp4_covers: list | None = mp4_tags.get("covr") # pyright: ignore[reportUnknownVariableType]
if mp4_covers:
artwork = Image.open(BytesIO(mp4_covers[0]))
if artwork:
image = artwork
except (
FileNotFoundError,
id3.ID3NoHeaderError, # pyright: ignore[reportPrivateImportUsage]
mp4.MP4MetadataError,
mp4.MP4StreamInfoError,
MutagenError,
) as e:
logger.error("Couldn't read album artwork", path=filepath, error=type(e).__name__)
return image
def audio_waveform_thumb(
filepath: Path, ext: str, size: int, pixel_ratio: float
) -> Image.Image | None:
"""Render a waveform image from an audio file.
Args:
filepath (Path): The path of the file.
ext (str): The file extension (with leading ".").
size (tuple[int,int]): The size of the thumbnail.
pixel_ratio (float): The screen pixel ratio.
"""
# BASE_SCALE used for drawing on a larger image and resampling down
# to provide an antialiased effect.
base_scale: int = 2
samples_per_bar: int = 3
size_scaled: int = size * base_scale
allow_small_min: bool = False
im: Image.Image | None = None
try:
bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64)
audio = AudioSegment.from_file(filepath, ext[1:]) # pyright: ignore[reportUnknownVariableType]
data = np.frombuffer(buffer=audio._data, dtype=np.int16)
data_indices = np.linspace(1, len(data), num=bar_count * samples_per_bar)
bar_margin: float = ((size_scaled / (bar_count * 3)) * base_scale) / 2
line_width: float = ((size_scaled - bar_margin) / (bar_count * 3)) * base_scale
bar_height: float = (size_scaled) - (size_scaled // bar_margin)
count: int = 0
maximum_item: int = 0
max_array: list[int] = []
highest_line: int = 0
for i in range(-1, len(data_indices)):
d = data[math.ceil(data_indices[i]) - 1]
if count < samples_per_bar:
count = count + 1
with catch_warnings(record=True):
if abs(d) > maximum_item:
maximum_item = int(abs(d))
else:
max_array.append(maximum_item)
if maximum_item > highest_line:
highest_line = maximum_item
maximum_item = 0
count = 1
line_ratio = max(highest_line / bar_height, 1)
im = Image.new("RGB", (size_scaled, size_scaled), color="#000000")
draw = ImageDraw.Draw(im)
current_x = bar_margin
for item in max_array:
item_height = item / line_ratio
# If small minimums are not allowed, raise all values
# smaller than the line width to the same value.
if not allow_small_min:
item_height = max(item_height, line_width)
current_y = (bar_height - item_height + (size_scaled // bar_margin)) // 2
draw.rounded_rectangle(
(
current_x,
current_y,
(current_x + line_width),
(current_y + item_height),
),
radius=100 * base_scale,
fill=("#FF0000"),
outline=("#FFFF00"),
width=max(math.ceil(line_width / 6), base_scale),
)
current_x = current_x + line_width + bar_margin
im.resize((size, size), Image.Resampling.BILINEAR)
except Exception as e:
logger.error("Couldn't render waveform", path=filepath.name, error=type(e).__name__)
return im
+43
View File
@@ -0,0 +1,43 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import structlog
from PIL import Image
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.renderers.vendored.blender_renderer import (
blend_thumb, # pyright: ignore[reportUnknownVariableType]
)
logger = structlog.get_logger(__name__)
def blender_thumb(filepath: Path) -> Image.Image | None:
"""Get an emended thumbnail from a Blender file, if a thumbnail is present.
Args:
filepath (Path): The path of the file.
"""
bg_color: str = (
"#1e1e1e"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#FFFFFF"
)
im: Image.Image | None = None
try:
if (blend_image := blend_thumb(str(filepath))) is not None:
bg = Image.new("RGB", blend_image.size, color=bg_color)
bg.paste(blend_image, mask=blend_image.getchannel(3))
im = bg
else:
logger.info(
f"[ThumbRenderer][BLENDER][INFO] {filepath.name} "
"Doesn't have an embedded thumbnail."
)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+80
View File
@@ -0,0 +1,80 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import base64
import sqlite3
import struct
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def clip_studio_thumb(filepath: Path) -> Image.Image | None:
"""Extract the thumbnail from the SQLite database embedded in a .clip file.
Args:
filepath (Path): The path of the .clip file.
Returns:
Image: The embedded thumbnail, if extractable.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
blob = f.read()
sqlite_index = blob.find(b"SQLite format 3")
if sqlite_index == -1:
return im
with sqlite3.connect(":memory:") as conn:
conn.deserialize(blob[sqlite_index:])
thumbnail = conn.execute("SELECT ImageData FROM CanvasPreview").fetchone()
if thumbnail:
im = Image.open(BytesIO(thumbnail[0]))
conn.close()
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def pdn_thumb(filepath: Path) -> Image.Image | None:
"""Extract the base64-encoded thumbnail from a .pdn file header.
Args:
filepath (Path): The path of the .pdn file.
Returns:
Image: the decoded PNG thumbnail or None by default.
"""
im: Image.Image | None = None
with open(filepath, "rb") as f:
try:
# First 4 bytes are the magic number
if f.read(4) != b"PDN3":
return im
# Header length is a little-endian 24-bit int
header_size = struct.unpack("<i", f.read(3) + b"\x00")[0]
thumb_element = ET.fromstring(f.read(header_size)).find("./*thumb")
if thumb_element is None:
return im
encoded_png = thumb_element.get("png")
if encoded_png:
decoded_png = base64.b64decode(encoded_png)
im = Image.open(BytesIO(decoded_png))
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+73
View File
@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import xml.etree.ElementTree as ET
from io import BytesIO
from pathlib import Path
from xml.etree.ElementTree import Element
import structlog
from PIL import Image
from tagstudio.core.media_types import MediaCategories
from tagstudio.core.utils.types import unwrap
from tagstudio.renderers.archive import Archive, first_image_in_archive, open_archive
from tagstudio.renderers.raster_image import image_from_bytes
logger = structlog.get_logger(__name__)
def epub_thumb(filepath: Path, ext: str) -> Image.Image | None:
"""Extracts the cover specified by ComicInfo.xml or first image found in the ePub file.
Args:
filepath (Path): The path to the ePub file.
ext (str): The file extension.
Returns:
Image: The cover specified in ComicInfo.xml,
the first image found in the ePub file, or None by default.
"""
im: Image.Image | None = None
try:
with open_archive(filepath, ext) as archive:
if "ComicInfo.xml" in archive.namelist():
comic_info = ET.fromstring(archive.read("ComicInfo.xml"))
im = _cover_from_comic_info(archive, comic_info, "FrontCover")
if not im:
im = _cover_from_comic_info(archive, comic_info, "InnerCover")
if not im:
im = first_image_in_archive(archive)
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def _cover_from_comic_info(
archive: Archive, comic_info: Element, cover_type: str
) -> Image.Image | None:
"""Extract the cover specified in ComicInfo.xml.
Args:
archive (Archive): The current ePub file.
comic_info (Element): The parsed ComicInfo.xml.
cover_type (str): The type of cover to load.
Returns:
Image: The cover specified in ComicInfo.xml.
"""
im: Image.Image | None = None
cover = comic_info.find(f"./*Page[@Type='{cover_type}']")
if cover is not None:
pages = [f for f in archive.namelist() if f != "ComicInfo.xml"] # pyright: ignore[reportUnknownVariableType]
page_name = pages[int(unwrap(cover.get("Image")))] # pyright: ignore[reportUnknownVariableType]
ext = Path(page_name).suffix
if MediaCategories.IMAGE_RASTER_TYPES.contains(ext):
image_data = archive.read(page_name) # pyright: ignore[reportUnknownVariableType]
im = image_from_bytes(BytesIO(image_data))
return im
+108
View File
@@ -0,0 +1,108 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from pathlib import Path
from typing import cast
import numpy as np
import structlog
from PIL import Image, ImageDraw, ImageFont
from tagstudio.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT
from tagstudio.qt.helpers.color_overlay import auto_theme_overlay
from tagstudio.qt.helpers.text_wrapper import wrap_full_text
logger = structlog.get_logger(__name__)
def font_small_thumb(filepath: Path, size: int) -> Image.Image | None:
"""Render a small font preview ("Aa") thumbnail from a font file.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
try:
bg = Image.new("RGB", (size, size), color="#000000")
raw = Image.new("RGB", (size * 3, size * 3), color="#000000")
draw = ImageDraw.Draw(raw)
font = ImageFont.truetype(filepath, size=size)
# NOTE: While a stroke effect is desired, the text
# method only allows for outer strokes, which looks
# a bit weird when rendering fonts.
draw.text(
(size // 8, size // 8),
"Aa",
font=font,
fill="#FF0000",
# stroke_width=math.ceil(size / 96),
# stroke_fill="#FFFF00",
)
# NOTE: Change to getchannel(1) if using an outline.
data = np.asarray(raw.getchannel(0))
m, n = data.shape[:2]
col: np.ndarray = cast(np.ndarray, data.any(0))
row: np.ndarray = cast(np.ndarray, data.any(1))
cropped_data = np.asarray(raw)[
row.argmax() : m - row[::-1].argmax(),
col.argmax() : n - col[::-1].argmax(),
]
cropped_im: Image.Image = Image.fromarray(cropped_data, "RGB")
margin: int = math.ceil(size // 16)
orig_x, orig_y = cropped_im.size
new_x, new_y = (size, size)
if orig_x > orig_y:
new_x = size
new_y = math.ceil(size * (orig_y / orig_x))
elif orig_y > orig_x:
new_y = size
new_x = math.ceil(size * (orig_x / orig_y))
cropped_im = cropped_im.resize(
size=(new_x - (margin * 2), new_y - (margin * 2)),
resample=Image.Resampling.BILINEAR,
)
bg.paste(
cropped_im,
box=(margin, margin + ((size - new_y) // 2)),
)
im = bg
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def font_full_preview(filepath: Path, size: int) -> Image.Image | None:
"""Render a large font preview ("Alphabet") thumbnail from a font file.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
# Scale the sample font sizes to the preview image
# resolution,assuming the sizes are tuned for 256px.
im: Image.Image | None = None
try:
scaled_sizes: list[int] = [math.floor(x * (size / 256)) for x in FONT_SAMPLE_SIZES]
bg = Image.new("RGBA", (size, size), color="#00000000")
draw = ImageDraw.Draw(bg)
lines_of_padding = 2
y_offset = 0.0
for font_size in scaled_sizes:
font = ImageFont.truetype(filepath, size=font_size)
text_wrapped: str = wrap_full_text(FONT_SAMPLE_TEXT, font=font, width=size, draw=draw)
draw.multiline_text((0, y_offset), text_wrapped, font=font)
y_offset += (len(text_wrapped.split("\n")) + lines_of_padding) * draw.textbbox(
(0, 0), "A", font=font
)[-1]
im = auto_theme_overlay(bg, use_alpha=False)
except OSError as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+58
View File
@@ -0,0 +1,58 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import os
import struct
import xml.etree.ElementTree as ET
import zlib
from pathlib import Path
import structlog
from PIL import Image
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
def medibang_paint_thumb(filepath: Path) -> Image.Image | None:
"""Extract the thumbnail from a .mdp file.
Args:
filepath (Path): The path of the .mdp file.
Returns:
Image: The embedded thumbnail.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
magic = struct.unpack("<7sx", f.read(8))[0]
if magic != b"mdipack":
return im
bin_header = struct.unpack("<LLL", f.read(12))
xml_header = ET.fromstring(f.read(bin_header[1]))
mdibin_count = len(xml_header.findall("./*Layer")) + 1
for _ in range(mdibin_count):
pac_header = struct.unpack("<3sxLLLL48s64s", f.read(132))
if not pac_header[6].startswith(b"thumb"):
f.seek(pac_header[3], os.SEEK_CUR)
continue
thumb_element = unwrap(xml_header.find("Thumb"))
dimensions = (
int(unwrap(thumb_element.get("width"))),
int(unwrap(thumb_element.get("height"))),
)
thumb_blob = f.read(pac_header[3])
if pac_header[2] == 1:
thumb_blob = zlib.decompress(thumb_blob, bufsize=pac_header[4])
im = Image.frombytes("RGBA", dimensions, thumb_blob, "raw", "BGRA")
break
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+66
View File
@@ -0,0 +1,66 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
from io import BytesIO
from pathlib import Path
import structlog
from PIL import Image
from PySide6.QtCore import QBuffer, QFile, QFileDevice, QIODeviceBase, QSizeF
from PySide6.QtGui import QImage
from PySide6.QtPdf import QPdfDocument, QPdfDocumentRenderOptions
from tagstudio.qt.helpers.image_effects import replace_transparent_pixels
logger = structlog.get_logger(__name__)
def pdf_thumb(filepath: Path, size: int, ext: str) -> Image.Image | None:
"""Render a thumbnail for a PDF or Adobe Illustrator file.
filepath (Path): The path of the file.
size (int): The size of the icon.
ext (str): The file extension.
"""
im: Image.Image | None = None
file: QFile = QFile(filepath)
success: bool = file.open(QIODeviceBase.OpenModeFlag.ReadOnly, QFileDevice.Permission.ReadUser)
if not success:
logger.error("Couldn't render thumbnail", filepath=filepath)
return im
document: QPdfDocument = QPdfDocument()
document.load(file)
file.close()
# Transform page_size in points to pixels with proper aspect ratio
page_size: QSizeF = document.pagePointSize(0)
ratio_hw: float = page_size.height() / page_size.width()
if ratio_hw >= 1:
page_size *= size / page_size.height()
else:
page_size *= size / page_size.width()
# Enlarge image for anti-aliasing
scale_factor = 2.5 if ext in {".pdf"} else 1
page_size *= scale_factor
# Render image with no anti-aliasing for speed
render_options: QPdfDocumentRenderOptions = QPdfDocumentRenderOptions()
render_options.setRenderFlags(
QPdfDocumentRenderOptions.RenderFlag.TextAliased
| QPdfDocumentRenderOptions.RenderFlag.ImageAliased
| QPdfDocumentRenderOptions.RenderFlag.PathAliased
)
# Convert QImage to PIL Image
q_image: QImage = document.render(0, page_size.toSize(), render_options)
buffer: QBuffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
try:
q_image.save(buffer, "PNG") # pyright: ignore
im = Image.open(BytesIO(buffer.buffer().data()))
finally:
buffer.close()
# Replace transparent pixels with white (otherwise Background defaults to transparent)
return replace_transparent_pixels(im)
+126
View File
@@ -0,0 +1,126 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import os
from io import BytesIO
from pathlib import Path
import cv2
import numpy as np
import rawpy
import structlog
from PIL import Image, ImageOps, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from pillow_heif import register_heif_opener # pyright: ignore[reportUnknownVariableType]
from rawpy import (
LibRawFileUnsupportedError, # pyright: ignore[reportPrivateImportUsage]
LibRawIOError, # pyright: ignore[reportPrivateImportUsage]
)
from tagstudio.core.utils.types import unwrap
logger = structlog.get_logger(__name__)
try:
import pillow_jxl # noqa: F401 # pyright: ignore
except ImportError as e:
logger.error('[ThumbRenderer] Could not import the "pillow_jxl" module', error=e)
register_heif_opener()
os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1"
def raster_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a standard image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with filepath.open("rb") as file:
im = image_from_bytes(BytesIO(file.read()))
except (
FileNotFoundError,
UnidentifiedImageError,
DecompressionBombError,
NotImplementedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def exr_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a EXR image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
# Load the EXR data to an array and rotate the color space from BGRA -> RGBA
raw_array = cv2.imread(str(filepath), cv2.IMREAD_UNCHANGED)
assert raw_array is not None
raw_array[..., :3] = raw_array[..., 2::-1]
# Correct the gamma of the raw array
gamma = 2.2
array_gamma = np.power(np.clip(raw_array, 0, 1), 1 / gamma)
array = (array_gamma * 255).astype(np.uint8)
im = Image.fromarray(array, mode="RGBA")
# Paste solid background
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
except Exception as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def raw_image_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a RAW image type.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with rawpy.imread(str(filepath)) as raw:
rgb = raw.postprocess(use_camera_wb=True)
im = Image.frombytes(
"RGB",
(rgb.shape[1], rgb.shape[0]),
rgb,
decoder_name="raw",
)
except (
DecompressionBombError,
LibRawIOError,
LibRawFileUnsupportedError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
def image_from_bytes(image_data: BytesIO) -> Image.Image:
"""Load a raster image and add a background if it's transparent.
Args:
image_data (BytesIO): The binary image data.
Returns:
Image.Image: The loaded raster image, with a background if needed.
"""
im: Image.Image = Image.open(image_data)
if im.mode != "RGB" and im.mode != "RGBA":
im = im.convert(mode="RGBA")
if im.mode == "RGBA":
new_bg = Image.new("RGB", im.size, color="#1e1e1e")
new_bg.paste(im, mask=im.getchannel(3))
im = new_bg
return unwrap(ImageOps.exif_transpose(im))
+30
View File
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import srctools
import structlog
from PIL import Image
logger = structlog.get_logger(__name__)
def vtf_thumb(filepath: Path) -> Image.Image | None:
"""Extract and render a thumbnail for VTF (Valve Texture Format) images.
Uses the srctools library for reading VTF files.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
try:
with open(filepath, "rb") as f:
vtf = srctools.VTF.read(f)
im = vtf.get(frame=0).to_PIL()
except (ValueError, FileNotFoundError) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+55
View File
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
from pathlib import Path
import cv2
import structlog
from PIL import Image, ImageDraw, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from tagstudio.core.utils.encoding import detect_char_encoding
logger = structlog.get_logger(__name__)
def text_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a plaintext file.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
bg_color: str = (
"#1e1e1e"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#FFFFFF"
)
fg_color: str = (
"#FFFFFF"
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
else "#111111"
)
try:
encoding = detect_char_encoding(filepath)
with open(filepath, encoding=encoding) as text_file:
text = text_file.read(256)
bg = Image.new("RGB", (256, 256), color=bg_color)
draw = ImageDraw.Draw(bg)
draw.text((16, 16), text, fill=fg_color)
im = bg
except (
UnidentifiedImageError,
cv2.error,
DecompressionBombError,
UnicodeDecodeError,
OSError,
FileNotFoundError,
) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
+56
View File
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# NOTE: This file contains Qt imports because Qt is used as the vector renderer in this case.
# This is NOT considered part of the Qt frontend, but is technically tangled with the Qt import.
from io import BytesIO
from pathlib import Path
import structlog
from PIL import (
Image,
UnidentifiedImageError,
)
from PySide6.QtCore import QBuffer, Qt
from PySide6.QtGui import QImage, QPainter
from PySide6.QtSvg import QSvgRenderer
logger = structlog.get_logger(__name__)
def vector_image_thumb(filepath: Path, size: int) -> Image.Image:
"""Render a thumbnail for a vector image, such as SVG.
Args:
filepath (Path): The path of the file.
size (tuple[int,int]): The size of the thumbnail.
"""
im: Image.Image | None = None
# Create an image to draw the svg to and a painter to do the drawing
q_image: QImage = QImage(size, size, QImage.Format.Format_ARGB32)
q_image.fill("#1e1e1e")
# Create an svg renderer, then render to the painter
svg: QSvgRenderer = QSvgRenderer(str(filepath))
if not svg.isValid():
raise UnidentifiedImageError
painter: QPainter = QPainter(q_image)
svg.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio)
svg.render(painter)
painter.end()
# Write the image to a buffer as png
buffer: QBuffer = QBuffer()
buffer.open(QBuffer.OpenModeFlag.ReadWrite)
q_image.save(buffer, "PNG") # pyright: ignore[reportCallIssue, reportArgumentType]
# Load the image from the buffer
im = Image.new("RGB", (size, size), color="#1e1e1e")
im.paste(Image.open(BytesIO(buffer.data().data())))
im = im.convert(mode="RGB")
buffer.close()
return im
@@ -1,11 +1,7 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: (c) 2017 The Blender Foundation
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# <pep8 compliant>
## This file is a modified script that gets the thumbnail data stored in a blend file
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) 2022 Karl Kroening (kkroening)
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro)
# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro), http://jiaaro.com
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from pydub
@@ -43,7 +43,7 @@ from pydub.utils import (
)
from tagstudio.core.utils.silent_subprocess import silent_popen
from tagstudio.qt.previews.vendored.pydub.utils import _mediainfo_json
from tagstudio.renderers.vendored.pydub.utils import _mediainfo_json
basestring = str
xrange = range
@@ -256,7 +256,7 @@ class _AudioSegment:
self.sample_width = 4
self.frame_width = self.channels * self.sample_width
super(_AudioSegment, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
@property
def raw_data(self):
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: (c) 2011 James Robert, http://jiaaro.com
# SPDX-FileCopyrightText: (c) 2011 James Robert (jiaaro), http://jiaaro.com
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
# Vendored from pydub
+55
View File
@@ -0,0 +1,55 @@
# SPDX-FileCopyrightText: (c) TagStudio Contributors
# SPDX-License-Identifier: GPL-3.0-only
import math
from pathlib import Path
import cv2
import structlog
from cv2.typing import MatLike
from PIL import Image, UnidentifiedImageError
from PIL.Image import DecompressionBombError
from tagstudio.qt.helpers.file_tester import is_readable_video
logger = structlog.get_logger(__name__)
def video_thumb(filepath: Path) -> Image.Image | None:
"""Render a thumbnail for a video file.
Args:
filepath (Path): The path of the file.
"""
im: Image.Image | None = None
frame: MatLike | None = None
try:
if is_readable_video(filepath):
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
# TODO: Move this check to is_readable_video()
if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0:
raise cv2.error("File is invalid or has 0 frames")
video.set(
cv2.CAP_PROP_POS_FRAMES,
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
)
# NOTE: 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.
max_frame_seek: int = 10
for i in range(
0,
min(max_frame_seek, math.floor(video.get(cv2.CAP_PROP_FRAME_COUNT))),
):
success, frame = video.read()
if not success:
video.set(cv2.CAP_PROP_POS_FRAMES, i)
else:
break
if frame is not None:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
im = Image.fromarray(frame)
except (UnidentifiedImageError, cv2.error, DecompressionBombError, OSError) as e:
logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__)
return im
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

@@ -10,6 +10,7 @@
"about.version.latest": "{built_version} (Latest Release: {latest_version})",
"about.website": "Website",
"app.git": "Git Commit",
"app.nightly": "Nightly",
"app.pre_release": "Pre-Release",
"app.title": "{base_title} - Library '{library_dir}'",
"color_manager.title": "Manage Tag Colors",
-8
View File
@@ -20,7 +20,6 @@ sys.path.insert(0, str(CWD.parent))
from tagstudio.core.constants import THUMB_CACHE_NAME, TS_FOLDER_NAME
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.utils.types import unwrap
from tagstudio.qt.thumb_grid_layout import ThumbGridLayout
from tagstudio.qt.ts_qt import QtDriver
@@ -36,22 +35,18 @@ def file_mediatypes_library():
status = lib.open_library(Path(""), in_memory=True)
assert status.success
folder = unwrap(lib.folder)
entry1 = Entry(
folder=folder,
path=Path("foo.png"),
fields=[TextField(name="Title", value="I'm a Test Title")],
)
entry2 = Entry(
folder=folder,
path=Path("bar.png"),
fields=[TextField(name="Title", value="I'm a Test Title")],
)
entry3 = Entry(
folder=folder,
path=Path("baz.apng"),
fields=[TextField(name="Title", value="I'm a Test Title")],
)
@@ -87,7 +82,6 @@ def library(request, library_dir: Path): # pyright: ignore
lib = Library()
status = lib.open_library(library_path, in_memory=True)
assert status.success
folder = unwrap(lib.folder)
tag = Tag(
name="foo",
@@ -116,7 +110,6 @@ def library(request, library_dir: Path): # pyright: ignore
# default item with deterministic name
entry = Entry(
id=1,
folder=folder,
path=Path("foo.txt"),
fields=[TextField(name="Title", value="I'm a Test Title")],
)
@@ -124,7 +117,6 @@ def library(request, library_dir: Path): # pyright: ignore
entry2 = Entry(
id=2,
folder=folder,
path=Path("one/two/bar.md"),
fields=[TextField(name="Title", value="I'm a Test Title")],
)
Binary file not shown.
-4
View File
@@ -8,25 +8,21 @@ from tagstudio.core.library.alchemy.fields import BaseField, TextField
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.library.alchemy.registries.dupe_files_registry import DupeFilesRegistry
from tagstudio.core.utils.types import unwrap
CWD = Path(__file__).parent
def test_refresh_dupe_files(library: Library):
library.library_dir = Path("/tmp/")
folder = unwrap(library.folder)
fields: list[BaseField] = [TextField(name="Title", value="I'm a Test Title")]
entry = Entry(
folder=folder,
path=Path("bar/foo.txt"),
fields=fields,
)
entry2 = Entry(
folder=folder,
path=Path("foo/foo.txt"),
fields=fields,
)
+1 -10
View File
@@ -77,7 +77,6 @@ def test_library_add_file(library: Library):
"""Check Entry.path handling for insert vs lookup"""
entry = Entry(
path=Path("bar.txt"),
folder=unwrap(library.folder),
fields=[TextField(name="Title", value="I'm a Test Title")],
)
@@ -139,8 +138,7 @@ def test_get_entry(library: Library, entry_min: Entry):
def test_entries_count(library: Library):
folder = unwrap(library.folder)
entries = [Entry(path=Path(f"{x}.txt"), folder=folder, fields=[]) for x in range(10)]
entries = [Entry(path=Path(f"{x}.txt"), fields=[]) for x in range(10)]
new_ids = library.add_entries(entries)
assert len(new_ids) == 10
@@ -254,7 +252,6 @@ def test_update_entry_with_multiple_identical_text_fields(library: Library, entr
def test_mirror_entry_fields(library: Library):
# Create and add entries with fields
entry_a = Entry(
folder=unwrap(library.folder),
path=Path("title_and_date.txt"),
fields=[
TextField(name="Title", value="I'm a Test Title"),
@@ -262,7 +259,6 @@ def test_mirror_entry_fields(library: Library):
],
)
entry_b = Entry(
folder=unwrap(library.folder),
path=Path("notes.txt"),
fields=[
TextField(name="Notes", value="These are my notes.\nNo peeking!", is_multiline=True),
@@ -270,7 +266,6 @@ def test_mirror_entry_fields(library: Library):
],
)
entry_c = Entry(
folder=unwrap(library.folder),
path=Path("date_published.txt"),
fields=[
DatetimeField(name="Date Published", value="2000-01-01 12:00:00"),
@@ -319,14 +314,11 @@ def test_mirror_entry_fields(library: Library):
def test_merge_entries(library: Library):
folder = unwrap(library.folder)
tag_0: Tag = unwrap(library.add_tag(Tag(id=1010, name="tag_0")))
tag_1: Tag = unwrap(library.add_tag(Tag(id=1011, name="tag_1")))
tag_2: Tag = unwrap(library.add_tag(Tag(id=1012, name="tag_2")))
entry_a = Entry(
folder=folder,
path=Path("a"),
fields=[
TextField(name="Author", value="Author McAuthorson"),
@@ -334,7 +326,6 @@ def test_merge_entries(library: Library):
],
)
entry_b = Entry(
folder=folder,
path=Path("b"),
fields=[TextField(name="Notes", value="test note", is_multiline=True)],
)