mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-07-25 14:54:17 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd6e427e02 | |||
| 0c21c45cb4 | |||
| d2bf445a13 | |||
| 8995ef6caf | |||
| 068add29df | |||
| f24653e030 | |||
| c30dee799a | |||
| 15230cc369 | |||
| c3b6bec2ff | |||
| 58496a7d2d | |||
| dc4251ff55 | |||
| 174262b9b3 | |||
| 27d761731c | |||
| 51a9c16f50 | |||
| 6aa0cf74f9 | |||
| 49b450c3a4 | |||
| a1dfa62e4a | |||
| aa2d9d4815 | |||
| 16cfa8d2ff | |||
| 9d5200b2f2 | |||
| f252a86fd5 | |||
| a0fb679729 | |||
| b182b2ff7e | |||
| 308b36b31e |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -27,12 +27,12 @@ hide:
|
||||
|
||||

|
||||
|
||||
**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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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 "-:--"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
-5
@@ -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
-1
@@ -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
|
||||
+3
-3
@@ -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
-1
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
Binary file not shown.
@@ -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
@@ -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)],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user