diff --git a/.gitignore b/.gitignore index 7b493926..c04f546f 100644 --- a/.gitignore +++ b/.gitignore @@ -232,11 +232,11 @@ compile_commands.json ### VisualStudioCode ### .vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets +# !.vscode/settings.json +# !.vscode/tasks.json +# !.vscode/launch.json +# !.vscode/extensions.json +# !.vscode/*.code-snippets # Local History for Visual Studio Code .history/ diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 8838fbb3..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "TagStudio", - "type": "python", - "request": "launch", - "program": "${workspaceRoot}/tagstudio/tag_studio.py", - "console": "integratedTerminal", - "justMyCode": true, - "args": [] - } - ] -} diff --git a/pyproject.toml b/pyproject.toml index 023ee6f1..ca949c6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ line-length = 100 [tool.ruff.lint.per-file-ignores] "tagstudio/tests/**" = ["D", "E402"] +"tagstudio/src/qt/helpers/vendored/**" = ["B", "E", "N", "UP", "SIM115"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/requirements.txt b/requirements.txt index 18be43fa..f2b7a90c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,8 @@ pillow-heif==0.16.0 chardet==5.2.0 structlog==24.4.0 SQLAlchemy==2.0.34 +pydub==0.25.1 +mutagen==1.47.0 +numpy==1.26.4 +ffmpeg-python==0.2.0 +vtf2img==0.1.0 diff --git a/tagstudio/resources/qt/images/broken_link_icon.png b/tagstudio/resources/qt/images/broken_link_icon.png new file mode 100644 index 00000000..d4310970 Binary files /dev/null and b/tagstudio/resources/qt/images/broken_link_icon.png differ diff --git a/tagstudio/resources/qt/images/file_icons/adobe_illustrator.png b/tagstudio/resources/qt/images/file_icons/adobe_illustrator.png new file mode 100644 index 00000000..141ae620 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/adobe_illustrator.png differ diff --git a/tagstudio/resources/qt/images/file_icons/adobe_photoshop.png b/tagstudio/resources/qt/images/file_icons/adobe_photoshop.png new file mode 100644 index 00000000..44a2c167 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/adobe_photoshop.png differ diff --git a/tagstudio/resources/qt/images/file_icons/affinity_photo.png b/tagstudio/resources/qt/images/file_icons/affinity_photo.png new file mode 100644 index 00000000..f4305fb8 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/affinity_photo.png differ diff --git a/tagstudio/resources/qt/images/file_icons/audio.png b/tagstudio/resources/qt/images/file_icons/audio.png new file mode 100644 index 00000000..9019de01 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/audio.png differ diff --git a/tagstudio/resources/qt/images/file_icons/document.png b/tagstudio/resources/qt/images/file_icons/document.png new file mode 100644 index 00000000..a3dacb01 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/document.png differ diff --git a/tagstudio/resources/qt/images/file_icons/file_generic.png b/tagstudio/resources/qt/images/file_icons/file_generic.png new file mode 100644 index 00000000..13685e3a Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/file_generic.png differ diff --git a/tagstudio/resources/qt/images/file_icons/font.png b/tagstudio/resources/qt/images/file_icons/font.png new file mode 100644 index 00000000..174750db Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/font.png differ diff --git a/tagstudio/resources/qt/images/file_icons/image.png b/tagstudio/resources/qt/images/file_icons/image.png new file mode 100644 index 00000000..94264aec Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/image.png differ diff --git a/tagstudio/resources/qt/images/file_icons/image_vector.png b/tagstudio/resources/qt/images/file_icons/image_vector.png new file mode 100644 index 00000000..f0e38a3d Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/image_vector.png differ diff --git a/tagstudio/resources/qt/images/file_icons/material.png b/tagstudio/resources/qt/images/file_icons/material.png new file mode 100644 index 00000000..0c0c10af Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/material.png differ diff --git a/tagstudio/resources/qt/images/file_icons/model.png b/tagstudio/resources/qt/images/file_icons/model.png new file mode 100644 index 00000000..631db6e9 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/model.png differ diff --git a/tagstudio/resources/qt/images/file_icons/presentation.png b/tagstudio/resources/qt/images/file_icons/presentation.png new file mode 100644 index 00000000..86a3b37c Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/presentation.png differ diff --git a/tagstudio/resources/qt/images/file_icons/program.png b/tagstudio/resources/qt/images/file_icons/program.png new file mode 100644 index 00000000..f7d64c1a Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/program.png differ diff --git a/tagstudio/resources/qt/images/file_icons/spreadsheet.png b/tagstudio/resources/qt/images/file_icons/spreadsheet.png new file mode 100644 index 00000000..fb1dbeac Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/spreadsheet.png differ diff --git a/tagstudio/resources/qt/images/file_icons/text.png b/tagstudio/resources/qt/images/file_icons/text.png new file mode 100644 index 00000000..79d7d91b Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/text.png differ diff --git a/tagstudio/resources/qt/images/file_icons/video.png b/tagstudio/resources/qt/images/file_icons/video.png new file mode 100644 index 00000000..5dae57a6 Binary files /dev/null and b/tagstudio/resources/qt/images/file_icons/video.png differ diff --git a/tagstudio/resources/qt/images/thumb_border_512.png b/tagstudio/resources/qt/images/thumb_border_512.png deleted file mode 100644 index 605717e3..00000000 Binary files a/tagstudio/resources/qt/images/thumb_border_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_broken_512.png b/tagstudio/resources/qt/images/thumb_broken_512.png deleted file mode 100644 index 5022f2eb..00000000 Binary files a/tagstudio/resources/qt/images/thumb_broken_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_file_default_512.png b/tagstudio/resources/qt/images/thumb_file_default_512.png deleted file mode 100644 index 28dfbd43..00000000 Binary files a/tagstudio/resources/qt/images/thumb_file_default_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_loading.png b/tagstudio/resources/qt/images/thumb_loading.png new file mode 100644 index 00000000..174a1879 Binary files /dev/null and b/tagstudio/resources/qt/images/thumb_loading.png differ diff --git a/tagstudio/resources/qt/images/thumb_loading_512.png b/tagstudio/resources/qt/images/thumb_loading_512.png deleted file mode 100644 index 05008af5..00000000 Binary files a/tagstudio/resources/qt/images/thumb_loading_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_loading_dark_512.png b/tagstudio/resources/qt/images/thumb_loading_dark_512.png deleted file mode 100644 index 7dcd99db..00000000 Binary files a/tagstudio/resources/qt/images/thumb_loading_dark_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_mask_128.png b/tagstudio/resources/qt/images/thumb_mask_128.png deleted file mode 100644 index 52a0a135..00000000 Binary files a/tagstudio/resources/qt/images/thumb_mask_128.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_mask_512.png b/tagstudio/resources/qt/images/thumb_mask_512.png deleted file mode 100644 index ce641abc..00000000 Binary files a/tagstudio/resources/qt/images/thumb_mask_512.png and /dev/null differ diff --git a/tagstudio/resources/qt/images/thumb_mask_hl_512.png b/tagstudio/resources/qt/images/thumb_mask_hl_512.png deleted file mode 100644 index 36c896b8..00000000 Binary files a/tagstudio/resources/qt/images/thumb_mask_hl_512.png and /dev/null differ diff --git a/tagstudio/src/core/constants.py b/tagstudio/src/core/constants.py index aceac14a..48c087fd 100644 --- a/tagstudio/src/core/constants.py +++ b/tagstudio/src/core/constants.py @@ -6,118 +6,10 @@ TS_FOLDER_NAME: str = ".TagStudio" BACKUP_FOLDER_NAME: str = "backups" COLLAGE_FOLDER_NAME: str = "collages" -# TODO: Turn this whitelist into a user-configurable blacklist. -IMAGE_TYPES: list[str] = [ - ".png", - ".jpg", - ".jpeg", - ".jpg_large", - ".jpeg_large", - ".jfif", - ".gif", - ".tif", - ".tiff", - ".heic", - ".heif", - ".webp", - ".bmp", - ".svg", - ".avif", - ".apng", - ".jp2", - ".j2k", - ".jpg2", -] -RAW_IMAGE_TYPES: list[str] = [ - ".raw", - ".dng", - ".rw2", - ".nef", - ".arw", - ".crw", - ".cr2", - ".cr3", -] -VIDEO_TYPES: list[str] = [ - ".mp4", - ".webm", - ".mov", - ".hevc", - ".mkv", - ".avi", - ".wmv", - ".flv", - ".gifv", - ".m4p", - ".m4v", - ".3gp", -] -AUDIO_TYPES: list[str] = [ - ".mp3", - ".mp4", - ".mpeg4", - ".m4a", - ".aac", - ".wav", - ".flac", - ".alac", - ".wma", - ".ogg", - ".aiff", -] -DOC_TYPES: list[str] = [ - ".txt", - ".rtf", - ".md", - ".doc", - ".docx", - ".pdf", - ".tex", - ".odt", - ".pages", -] -PLAINTEXT_TYPES: list[str] = [ - ".txt", - ".md", - ".css", - ".html", - ".xml", - ".json", - ".js", - ".ts", - ".ini", - ".htm", - ".csv", - ".php", - ".sh", - ".bat", -] -SPREADSHEET_TYPES: list[str] = [".csv", ".xls", ".xlsx", ".numbers", ".ods"] -PRESENTATION_TYPES: list[str] = [".ppt", ".pptx", ".key", ".odp"] -ARCHIVE_TYPES: list[str] = [ - ".zip", - ".rar", - ".tar", - ".tar", - ".gz", - ".tgz", - ".7z", - ".s7z", -] -PROGRAM_TYPES: list[str] = [".exe", ".app"] -SHORTCUT_TYPES: list[str] = [".lnk", ".desktop", ".url"] - -ALL_FILE_TYPES: list[str] = ( - IMAGE_TYPES - + VIDEO_TYPES - + AUDIO_TYPES - + DOC_TYPES - + SPREADSHEET_TYPES - + PRESENTATION_TYPES - + ARCHIVE_TYPES - + PROGRAM_TYPES - + SHORTCUT_TYPES +FONT_SAMPLE_TEXT: str = ( + """ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!?@$%(){}[]""" ) +FONT_SAMPLE_SIZES: list[int] = [10, 15, 20] TAG_FAVORITE = 1 TAG_ARCHIVED = 0 diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py index d457f716..d4a9aa3d 100644 --- a/tagstudio/src/core/enums.py +++ b/tagstudio/src/core/enums.py @@ -14,7 +14,11 @@ class SettingItems(str, enum.Enum): class Theme(str, enum.Enum): + COLOR_BG_DARK = "#65000000" + COLOR_BG_LIGHT = "#22000000" + COLOR_DARK_LABEL = "#DD000000" COLOR_BG = "#65000000" + COLOR_HOVER = "#65AAAAAA" COLOR_PRESSED = "#65EEEEEE" COLOR_DISABLED = "#65F39CAA" diff --git a/tagstudio/src/core/media_types.py b/tagstudio/src/core/media_types.py new file mode 100644 index 00000000..bb190700 --- /dev/null +++ b/tagstudio/src/core/media_types.py @@ -0,0 +1,507 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import logging +import mimetypes +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +logging.basicConfig(format="%(message)s", level=logging.INFO) + + +class MediaType(str, Enum): + """Names of media types.""" + + ADOBE_PHOTOSHOP: str = "adobe_photoshop" + AFFINITY_PHOTO: str = "affinity_photo" + ARCHIVE: str = "archive" + AUDIO_MIDI: str = "audio_midi" + AUDIO: str = "audio" + BLENDER: str = "blender" + DATABASE: str = "database" + DISK_IMAGE: str = "disk_image" + DOCUMENT: str = "document" + FONT: str = "font" + IMAGE_ANIMATED: str = "image_animated" + IMAGE_RAW: str = "image_raw" + IMAGE_VECTOR: str = "image_vector" + IMAGE: str = "image" + INSTALLER: str = "installer" + MATERIAL: str = "material" + MODEL: str = "model" + PACKAGE: str = "package" + PDF: str = "pdf" + PLAINTEXT: str = "plaintext" + PRESENTATION: str = "presentation" + PROGRAM: str = "program" + SHORTCUT: str = "shortcut" + SOURCE_ENGINE: str = "source_engine" + SPREADSHEET: str = "spreadsheet" + TEXT: str = "text" + VIDEO: str = "video" + + +@dataclass(frozen=True) +class MediaCategory: + """An object representing a category of media. + + Includes a MediaType identifier, extensions set, and IANA status flag. + + Args: + media_type (MediaType): The MediaType Enum representing this category. + + extensions (set[str]): The set of file extensions associated with this category. + Includes leading ".", all lowercase, and does not need to be unique to this category. + + is_iana (bool): Represents whether or not this is an IANA registered category. + """ + + media_type: MediaType + extensions: set[str] + is_iana: bool = False + + +class MediaCategories: + """Contain pre-made MediaCategory objects as well as methods to interact with them.""" + + # 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_PHOTOSHOP_SET: set[str] = { + ".pdd", + ".psb", + ".psd", + } + _AFFINITY_PHOTO_SET: set[str] = {".afphoto"} + _ARCHIVE_SET: set[str] = { + ".7z", + ".gz", + ".rar", + ".s7z", + ".tar", + ".tgz", + ".zip", + } + _AUDIO_MIDI_SET: set[str] = { + ".mid", + ".midi", + } + _AUDIO_SET: set[str] = { + ".aac", + ".aif", + ".aiff", + ".alac", + ".flac", + ".m4a", + ".m4p", + ".mp3", + ".mpeg4", + ".ogg", + ".wav", + ".wma", + } + _BLENDER_SET: set[str] = { + ".blen_tc", + ".blend", + ".blend1", + ".blend2", + ".blend3", + ".blend4", + ".blend5", + ".blend6", + ".blend7", + ".blend8", + ".blend9", + ".blend10", + ".blend11", + ".blend12", + ".blend13", + ".blend14", + ".blend15", + ".blend16", + ".blend17", + ".blend18", + ".blend19", + ".blend20", + ".blend21", + ".blend22", + ".blend23", + ".blend24", + ".blend25", + ".blend26", + ".blend27", + ".blend28", + ".blend29", + ".blend30", + ".blend31", + ".blend32", + } + _DATABASE_SET: set[str] = { + ".accdb", + ".mdb", + ".sqlite", + ".sqlite3", + } + _DISK_IMAGE_SET: set[str] = {".bios", ".dmg", ".iso"} + _DOCUMENT_SET: set[str] = { + ".doc", + ".docm", + ".docx", + ".dot", + ".dotm", + ".dotx", + ".odt", + ".pages", + ".pdf", + ".rtf", + ".tex", + ".wpd", + ".wps", + } + _FONT_SET: set[str] = { + ".fon", + ".otf", + ".ttc", + ".ttf", + ".woff", + ".woff2", + } + _IMAGE_ANIMATED_SET: set[str] = { + ".apng", + ".gif", + ".webp", + ".jxl", + } + _IMAGE_RAW_SET: set[str] = { + ".arw", + ".cr2", + ".cr3", + ".crw", + ".dng", + ".nef", + ".orf", + ".raf", + ".raw", + ".rw2", + } + _IMAGE_VECTOR_SET: set[str] = {".svg"} + _IMAGE_SET: set[str] = { + ".apng", + ".avif", + ".bmp", + ".exr", + ".gif", + ".heic", + ".heif", + ".j2k", + ".jfif", + ".jp2", + ".jpeg_large", + ".jpeg", + ".jpg_large", + ".jpg", + ".jpg2", + ".jxl", + ".png", + ".psb", + ".psd", + ".tif", + ".tiff", + ".webp", + } + _INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"} + _MATERIAL_SET: set[str] = {".mtl"} + _MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"} + _PACKAGE_SET: set[str] = { + ".aab", + ".akp", + ".apk", + ".apkm", + ".apks", + ".pkg", + ".xapk", + } + _PDF_SET: set[str] = { + ".pdf", + } + _PLAINTEXT_SET: set[str] = { + ".bat", + ".cfg", + ".conf", + ".cpp", + ".cs", + ".css", + ".csv", + ".fgd", + ".gi", + ".h", + ".hpp", + ".htm", + ".html", + ".inf", + ".ini", + ".js", + ".json", + ".jsonc", + ".kv3", + ".lua", + ".md", + ".nut", + ".php", + ".plist", + ".prefs", + ".py", + ".pyc", + ".qss", + ".sh", + ".toml", + ".ts", + ".txt", + ".vcfg", + ".vdf", + ".vmt", + ".vqlayout", + ".vsc", + ".vsnd_template", + ".xml", + ".yaml", + ".yml", + } + _PRESENTATION_SET: set[str] = { + ".key", + ".odp", + ".ppt", + ".pptx", + } + _PROGRAM_SET: set[str] = {".app", ".exe"} + _SOURCE_ENGINE_SET: set[str] = { + ".vtf", + } + _SHORTCUT_SET: set[str] = {".desktop", ".lnk", ".url"} + _SPREADSHEET_SET: set[str] = { + ".csv", + ".numbers", + ".ods", + ".xls", + ".xlsx", + } + _VIDEO_SET: set[str] = { + ".3gp", + ".avi", + ".flv", + ".gifv", + ".hevc", + ".m4p", + ".m4v", + ".mkv", + ".mov", + ".mp4", + ".webm", + ".wmv", + } + + ADOBE_PHOTOSHOP_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.ADOBE_PHOTOSHOP, + extensions=_ADOBE_PHOTOSHOP_SET, + is_iana=False, + ) + AFFINITY_PHOTO_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.AFFINITY_PHOTO, + extensions=_AFFINITY_PHOTO_SET, + is_iana=False, + ) + ARCHIVE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.ARCHIVE, + extensions=_ARCHIVE_SET, + is_iana=False, + ) + AUDIO_MIDI_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.AUDIO_MIDI, + extensions=_AUDIO_MIDI_SET, + is_iana=False, + ) + AUDIO_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.AUDIO, + extensions=_AUDIO_SET | _AUDIO_MIDI_SET, + is_iana=True, + ) + BLENDER_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.BLENDER, + extensions=_BLENDER_SET, + is_iana=False, + ) + DATABASE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.DATABASE, + extensions=_DATABASE_SET, + is_iana=False, + ) + DISK_IMAGE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.DISK_IMAGE, + extensions=_DISK_IMAGE_SET, + is_iana=False, + ) + DOCUMENT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.DOCUMENT, + extensions=_DOCUMENT_SET, + is_iana=False, + ) + FONT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.FONT, + extensions=_FONT_SET, + is_iana=True, + ) + IMAGE_ANIMATED_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE_ANIMATED, + extensions=_IMAGE_ANIMATED_SET, + is_iana=False, + ) + IMAGE_RAW_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE_RAW, + extensions=_IMAGE_RAW_SET, + is_iana=False, + ) + IMAGE_VECTOR_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE_VECTOR, + extensions=_IMAGE_VECTOR_SET, + is_iana=False, + ) + IMAGE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.IMAGE, + extensions=_IMAGE_SET | _IMAGE_RAW_SET | _IMAGE_VECTOR_SET, + is_iana=True, + ) + INSTALLER_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.INSTALLER, + extensions=_INSTALLER_SET, + is_iana=False, + ) + MATERIAL_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.MATERIAL, + extensions=_MATERIAL_SET, + is_iana=False, + ) + MODEL_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.MODEL, + extensions=_MODEL_SET, + is_iana=True, + ) + PACKAGE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PACKAGE, + extensions=_PACKAGE_SET, + is_iana=False, + ) + PDF_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PDF, + extensions=_PDF_SET, + is_iana=False, + ) + PLAINTEXT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PLAINTEXT, + extensions=_PLAINTEXT_SET, + is_iana=False, + ) + PRESENTATION_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PRESENTATION, + extensions=_PRESENTATION_SET, + is_iana=False, + ) + PROGRAM_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.PROGRAM, + extensions=_PROGRAM_SET, + is_iana=False, + ) + SHORTCUT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.SHORTCUT, + extensions=_SHORTCUT_SET, + is_iana=False, + ) + SOURCE_ENGINE_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.SOURCE_ENGINE, + extensions=_SOURCE_ENGINE_SET, + is_iana=False, + ) + SPREADSHEET_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.SPREADSHEET, + extensions=_SPREADSHEET_SET, + is_iana=False, + ) + TEXT_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.TEXT, + extensions=_DOCUMENT_SET | _PLAINTEXT_SET, + is_iana=True, + ) + VIDEO_TYPES: MediaCategory = MediaCategory( + media_type=MediaType.VIDEO, + extensions=_VIDEO_SET, + is_iana=True, + ) + + ALL_CATEGORIES: list[MediaCategory] = [ + ADOBE_PHOTOSHOP_TYPES, + AFFINITY_PHOTO_TYPES, + ARCHIVE_TYPES, + AUDIO_MIDI_TYPES, + AUDIO_TYPES, + BLENDER_TYPES, + DATABASE_TYPES, + DISK_IMAGE_TYPES, + DOCUMENT_TYPES, + FONT_TYPES, + IMAGE_ANIMATED_TYPES, + IMAGE_RAW_TYPES, + IMAGE_TYPES, + IMAGE_VECTOR_TYPES, + INSTALLER_TYPES, + MATERIAL_TYPES, + MODEL_TYPES, + PACKAGE_TYPES, + PDF_TYPES, + PLAINTEXT_TYPES, + PRESENTATION_TYPES, + PROGRAM_TYPES, + SHORTCUT_TYPES, + SOURCE_ENGINE_TYPES, + SPREADSHEET_TYPES, + TEXT_TYPES, + VIDEO_TYPES, + ] + + @staticmethod + def get_types(ext: str, mime_fallback: bool = False) -> set[MediaType]: + """Return a set of MediaTypes given a file extension. + + Args: + ext (str): File extension with a leading "." and in all lowercase. + mime_fallback (bool): Flag to guess MIME type if no set matches are made. + """ + media_types: set[MediaType] = set() + # mime_guess: bool = False + + for cat in MediaCategories.ALL_CATEGORIES: + if ext in cat.extensions: + media_types.add(cat.media_type) + elif mime_fallback and cat.is_iana: + mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] + if mime_type and mime_type.startswith(cat.media_type.value): + media_types.add(cat.media_type) + # mime_guess = True + return media_types + + @staticmethod + def is_ext_in_category(ext: str, media_cat: MediaCategory, mime_fallback: bool = False) -> bool: + """Check if an extension is a member of a MediaCategory. + + Args: + ext (str): File extension with a leading "." and in all lowercase. + media_cat (MediaCategory): The MediaCategory to to check for extension membership. + mime_fallback (bool): Flag to guess MIME type if no set matches are made. + """ + if ext in media_cat.extensions: + return True + elif mime_fallback and media_cat.is_iana: + mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0] + if mime_type and mime_type.startswith(media_cat.media_type.value): + return True + return False diff --git a/tagstudio/src/core/palette.py b/tagstudio/src/core/palette.py index f104b692..422b35f7 100644 --- a/tagstudio/src/core/palette.py +++ b/tagstudio/src/core/palette.py @@ -19,6 +19,15 @@ class ColorType(IntEnum): DARK_ACCENT = 4 +class UiColor(IntEnum): + DEFAULT = 0 + THEME_DARK = 1 + THEME_LIGHT = 2 + RED = 3 + GREEN = 4 + PURPLE = 5 + + TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = { TagColor.DEFAULT: { ColorType.PRIMARY: "#1e1e1e", @@ -283,8 +292,56 @@ TAG_COLORS: dict[TagColor, dict[ColorType, Any]] = { }, } +UI_COLORS: dict[UiColor, dict[ColorType, Any]] = { + UiColor.DEFAULT: { + ColorType.PRIMARY: "#333333", + ColorType.BORDER: "#555555", + ColorType.LIGHT_ACCENT: "#FFFFFF", + ColorType.DARK_ACCENT: "#1e1e1e", + }, + UiColor.RED: { + ColorType.PRIMARY: "#e22c3c", + ColorType.BORDER: "#e54252", + ColorType.LIGHT_ACCENT: "#f39caa", + ColorType.DARK_ACCENT: "#440d12", + }, + UiColor.GREEN: { + ColorType.PRIMARY: "#28bb48", + ColorType.BORDER: "#43c568", + ColorType.LIGHT_ACCENT: "#DDFFCC", + ColorType.DARK_ACCENT: "#0d3828", + }, + UiColor.PURPLE: { + ColorType.PRIMARY: "#C76FF3", + ColorType.BORDER: "#c364f2", + ColorType.LIGHT_ACCENT: "#EFD4FB", + ColorType.DARK_ACCENT: "#3E1555", + }, + UiColor.THEME_DARK: { + ColorType.PRIMARY: "#333333", + ColorType.BORDER: "#555555", + ColorType.LIGHT_ACCENT: "#FFFFFF", + ColorType.DARK_ACCENT: "#1e1e1e", + }, + UiColor.THEME_LIGHT: { + ColorType.PRIMARY: "#FFFFFF", + ColorType.BORDER: "#333333", + ColorType.LIGHT_ACCENT: "#999999", + ColorType.DARK_ACCENT: "#888888", + }, +} + def get_tag_color(color_type: ColorType, color_id: TagColor) -> str: + """Return a hex value given a tag color name and ColorType. + + Args: + color_type (ColorType): The ColorType category to retrieve from. + color_id (ColorType): The color name enum to retrieve from. + + Return: + A hex value string representing a color with a leading "#". + """ try: if color_type == ColorType.TEXT: text_account: ColorType = TAG_COLORS[color_id][color_type] @@ -293,5 +350,23 @@ def get_tag_color(color_type: ColorType, color_id: TagColor) -> str: return TAG_COLORS[color_id][color_type] except KeyError: traceback.print_stack() - logger.error("Color not found", color_id=color_id) + logger.error("[PALETTE] Tag color not found.", color_id=color_id) + return "#FF00FF" + + +def get_ui_color(color_type: ColorType, color_id: UiColor) -> str: + """Return a hex value given a UI color name and ColorType. + + Args: + color_type (ColorType): The ColorType category to retrieve from. + color_id (UiColor): The color name enum to retrieve from. + + Return: + A hex value string representing a color with a leading "#". + """ + try: + return UI_COLORS[color_id][color_type] + except KeyError: + traceback.print_stack() + logger.error("[PALETTE] UI color not found", color_id=color_id) return "#FF00FF" diff --git a/tagstudio/src/qt/helpers/blender_thumbnailer.py b/tagstudio/src/qt/helpers/blender_thumbnailer.py new file mode 100644 index 00000000..2df0a350 --- /dev/null +++ b/tagstudio/src/qt/helpers/blender_thumbnailer.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 + +# ##### BEGIN GPL LICENSE BLOCK ##### +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# ##### END GPL LICENSE BLOCK ##### + +# + + +## This file is a modified script that gets the thumbnail data stored in a blend file + + +import gzip +import os +import struct +from io import BufferedReader + +from PIL import ( + Image, + ImageOps, +) + + +def blend_extract_thumb(path): + rend = b"REND" + test = b"TEST" + + blendfile: BufferedReader | gzip.GzipFile = open(path, "rb") # noqa: SIM115 + + head = blendfile.read(12) + + if head[0:2] == b"\x1f\x8b": # gzip magic + blendfile.close() + blendfile = gzip.GzipFile("", "rb", 0, open(path, "rb")) # noqa: SIM115 + head = blendfile.read(12) + + if not head.startswith(b"BLENDER"): + blendfile.close() + return None, 0, 0 + + is_64_bit = head[7] == b"-"[0] + + # true for PPC, false for X86 + is_big_endian = head[8] == b"V"[0] + + # blender pre 2.5 had no thumbs + if head[9:11] <= b"24": + return None, 0, 0 + + sizeof_bhead = 24 if is_64_bit else 20 + int_endian = ">i" if is_big_endian else " Image.Image: +def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image: """Overlay the foreground theme color onto an image. Args: image (Image): The PIL Image object to apply an overlay to. + use_alpha (bool): Option to retain the base image's alpha value when applying the overlay. """ + dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG + light_fg: str = _THEME_LIGHT_FG[:-2] if not use_alpha else _THEME_LIGHT_FG + overlay_color = ( - _THEME_DARK_FG - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else _THEME_LIGHT_FG + dark_fg if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else light_fg ) + im = Image.new(mode="RGBA", size=image.size, color=overlay_color) return _apply_overlay(image, im) @@ -42,7 +47,7 @@ def gradient_overlay(image: Image.Image, gradient=list[str]) -> Image.Image: def _apply_overlay(image: Image.Image, overlay: Image.Image) -> Image.Image: - """Apply an overlay on top of an image, using the image's alpha channel as a mask. + """Apply an overlay on top of an image using the image's alpha channel as a mask. Args: image (Image): The PIL Image object to apply an overlay to. diff --git a/tagstudio/src/qt/helpers/file_tester.py b/tagstudio/src/qt/helpers/file_tester.py new file mode 100644 index 00000000..022ac191 --- /dev/null +++ b/tagstudio/src/qt/helpers/file_tester.py @@ -0,0 +1,32 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + + +from pathlib import Path + +import ffmpeg +from src.qt.helpers.vendored.ffmpeg import _probe + + +def is_readable_video(filepath: Path | str): + """Test if a video is in a readable format. + + Examples of unreadable videos include files with undetermined codecs and DRM-protected content. + + Args: + filepath (Path | str): The filepath of the video to check. + """ + try: + probe = _probe(Path(filepath)) + for stream in probe["streams"]: + # DRM check + if stream.get("codec_tag_string") in [ + "drma", + "drms", + "drmi", + ]: + return False + except ffmpeg.Error: + return False + return True diff --git a/tagstudio/src/qt/helpers/gradient.py b/tagstudio/src/qt/helpers/gradient.py index a5f3b9fa..fe3f7c7d 100644 --- a/tagstudio/src/qt/helpers/gradient.py +++ b/tagstudio/src/qt/helpers/gradient.py @@ -2,26 +2,14 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -from PIL import Image, ImageChops, ImageEnhance +from PIL import Image -def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl) -> Image.Image: - if image.size != (adj_size, adj_size): - # Old 1 color method. - # bg_col = image.copy().resize((1, 1)).getpixel((0,0)) - # bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col) - # bg.thumbnail((1, 1)) - # bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST) - - # Small gradient background. Looks decent, and is only a one-liner. - # bg = ( - # image.copy() - # .resize((2, 2), resample=Image.Resampling.BILINEAR) - # .resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR) - # ) - +def four_corner_gradient( + image: Image.Image, size: tuple[int, int], mask: Image.Image +) -> Image.Image: + if image.size != size: # Four-Corner Gradient Background. - # Not exactly a one-liner, but it's (subjectively) really cool. tl = image.getpixel((0, 0)) tr = image.getpixel(((image.size[0] - 1), 0)) bl = image.getpixel((0, (image.size[1] - 1))) @@ -31,26 +19,25 @@ def four_corner_gradient_background(image: Image.Image, adj_size, mask, hl) -> I bg.paste(tr, (1, 0, 2, 2)) bg.paste(bl, (0, 1, 2, 2)) bg.paste(br, (1, 1, 2, 2)) - bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC) - + bg = bg.resize(size, resample=Image.Resampling.BICUBIC) bg.paste( image, box=( - (adj_size - image.size[0]) // 2, - (adj_size - image.size[1]) // 2, + (size[0] - image.size[0]) // 2, + (size[1] - image.size[1]) // 2, ), ) - bg.putalpha(mask) - final = bg + final = Image.new("RGBA", bg.size, (0, 0, 0, 0)) + final.paste(bg, mask=mask.getchannel(0)) else: - image.putalpha(mask) - final = image + final = Image.new("RGBA", size, (0, 0, 0, 0)) + final.paste(image, mask=mask.getchannel(0)) + + if final.mode != "RGBA": + final = final.convert("RGBA") - hl_soft = hl.copy() - hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5)) - final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3)) return final diff --git a/tagstudio/src/qt/helpers/rounded_pixmap_style.py b/tagstudio/src/qt/helpers/rounded_pixmap_style.py new file mode 100644 index 00000000..36c33c79 --- /dev/null +++ b/tagstudio/src/qt/helpers/rounded_pixmap_style.py @@ -0,0 +1,29 @@ +# Based on the implementation by eyllanesc: +# https://stackoverflow.com/questions/54230005/qmovie-with-border-radius +# Licensed under the Creative Commons CC BY-SA 4.0 License: +# https://creativecommons.org/licenses/by-sa/4.0/ +# Modified for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PySide6.QtGui import QBrush, QColor, QPainter, QPixmap +from PySide6.QtWidgets import ( + QProxyStyle, +) + + +class RoundedPixmapStyle(QProxyStyle): + def __init__(self, radius=8): + super().__init__() + self._radius = radius + + def drawItemPixmap(self, painter, rectangle, alignment, pixmap): # noqa: N802 + painter.save() + pix = QPixmap(pixmap.size()) + pix.fill(QColor("transparent")) + p = QPainter(pix) + p.setBrush(QBrush(pixmap)) + p.setPen(QColor("transparent")) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.drawRoundedRect(pixmap.rect(), self._radius, self._radius) + p.end() + super().drawItemPixmap(painter, rectangle, alignment, pix) + painter.restore() diff --git a/tagstudio/src/qt/helpers/silent_popen.py b/tagstudio/src/qt/helpers/silent_popen.py new file mode 100644 index 00000000..df426fe5 --- /dev/null +++ b/tagstudio/src/qt/helpers/silent_popen.py @@ -0,0 +1,70 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import subprocess +import sys + +"""Implementation of subprocess.Popen that does not spawn console windows or log output.""" + + +def promptless_Popen( # noqa: N802 + args, + bufsize=-1, + executable=None, + stdin=None, + stdout=None, + stderr=None, + preexec_fn=None, + close_fds=True, + shell=False, + cwd=None, + env=None, + universal_newlines=None, + startupinfo=None, + restore_signals=True, + start_new_session=False, + pass_fds=(), + *, + group=None, + extra_groups=None, + user=None, + umask=-1, + encoding=None, + errors=None, + text=None, + pipesize=-1, + process_group=None, +): + """Call subprocess.Popen without creating a console window.""" + creation_flags = 0 + if sys.platform == "win32": + creation_flags = subprocess.CREATE_NO_WINDOW + + return subprocess.Popen( + args=args, + bufsize=bufsize, + executable=executable, + stdin=stdin, + stdout=stdout, + stderr=stderr, + preexec_fn=preexec_fn, + close_fds=close_fds, + shell=shell, + cwd=cwd, + env=env, + universal_newlines=universal_newlines, + startupinfo=startupinfo, + creationflags=creation_flags, + restore_signals=restore_signals, + start_new_session=start_new_session, + pass_fds=pass_fds, + group=group, + extra_groups=extra_groups, + user=user, + umask=umask, + encoding=encoding, + errors=errors, + text=text, + pipesize=pipesize, + process_group=process_group, + ) diff --git a/tagstudio/src/qt/helpers/text_wrapper.py b/tagstudio/src/qt/helpers/text_wrapper.py new file mode 100644 index 00000000..073f6e15 --- /dev/null +++ b/tagstudio/src/qt/helpers/text_wrapper.py @@ -0,0 +1,49 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PIL import Image, ImageDraw, ImageFont + + +def wrap_line( # type: ignore + text: str, + font: ImageFont.ImageFont, + width: int = 256, + draw: ImageDraw.ImageDraw = None, +) -> int: + """Take in a single text line and return the index it should be broken up at. + + Only splits once. + """ + if draw is None: + bg = Image.new("RGB", (width, width), color="#1e1e1e") + draw = ImageDraw.Draw(bg) + if draw.textlength(text, font=font) > width: + for i in range( + int(len(text) / int(draw.textlength(text, font=font)) * width) - 2, + 0, + -1, + ): + if draw.textlength(text[:i], font=font) < width: + return i + else: + return -1 + + +def wrap_full_text( + text: str, + font: ImageFont.ImageFont, + width: int = 256, + draw: ImageDraw.ImageDraw = None, +) -> str: + """Break up a string to fit the canvas given a kerning value, font size, etc.""" + lines = [] + i = 0 + last_i = 0 + while wrap_line(text[i:], font=font, width=width, draw=draw) > 0: + i = wrap_line(text[i:], font=font, width=width, draw=draw) + last_i + lines.append(text[last_i:i]) + last_i = i + lines.append(text[last_i:]) + text_wrapped = "\n".join(lines) + return text_wrapped diff --git a/tagstudio/src/qt/helpers/vendored/ffmpeg.py b/tagstudio/src/qt/helpers/vendored/ffmpeg.py new file mode 100644 index 00000000..097e78a2 --- /dev/null +++ b/tagstudio/src/qt/helpers/vendored/ffmpeg.py @@ -0,0 +1,33 @@ +# Copyright (C) 2022 Karl Kroening (kkroening). +# Licensed under the GPL-3.0 License. +# Vendored from ffmpeg-python and ffmpeg-python PR#790 by amamic1803 + +import json +import subprocess + +import ffmpeg +from src.qt.helpers.silent_popen import promptless_Popen + + +def _probe(filename, cmd="ffprobe", timeout=None, **kwargs): + """Run ffprobe on the specified file and return a JSON representation of the output. + + Raises: + :class:`ffmpeg.Error`: if ffprobe returns a non-zero exit code, + an :class:`Error` is returned with a generic error message. + The stderr output can be retrieved by accessing the + ``stderr`` property of the exception. + """ + args = [cmd, "-show_format", "-show_streams", "-of", "json"] + args += ffmpeg._utils.convert_kwargs_to_cmd_line_args(kwargs) + args += [filename] + + # PATCHED + p = promptless_Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + communicate_kwargs = {} + if timeout is not None: + communicate_kwargs["timeout"] = timeout + out, err = p.communicate(**communicate_kwargs) + if p.returncode != 0: + raise ffmpeg.Error("ffprobe", out, err) + return json.loads(out.decode("utf-8")) diff --git a/tagstudio/src/qt/helpers/vendored/pydub/audio_segment.py b/tagstudio/src/qt/helpers/vendored/pydub/audio_segment.py new file mode 100644 index 00000000..a43ea5b3 --- /dev/null +++ b/tagstudio/src/qt/helpers/vendored/pydub/audio_segment.py @@ -0,0 +1,1444 @@ +# type: ignore +# Copyright (C) 2022 James Robert (jiaaro). +# Licensed under the MIT License. +# Vendored from pydub + + +import array +import base64 +import os +import struct +import subprocess +import sys +import wave +from collections import namedtuple +from io import BytesIO, StringIO +from tempfile import NamedTemporaryFile + +from pydub.logging_utils import log_conversion, log_subprocess_output +from pydub.utils import fsdecode + +try: + from itertools import izip +except Exception: + izip = zip + +from pydub.exceptions import ( + CouldntDecodeError, + CouldntEncodeError, + InvalidDuration, + InvalidID3TagVersion, + InvalidTag, + MissingAudioParameter, + TooManyMissingFrames, +) +from pydub.utils import ( + _fd_or_path_or_tempfile, + audioop, + db_to_float, + get_array_type, + get_encoder_name, + ratio_to_db, +) +from src.qt.helpers.silent_popen import promptless_Popen +from src.qt.helpers.vendored.pydub.utils import _mediainfo_json + +basestring = str +xrange = range +StringIO = BytesIO # noqa: F811 + + +class ClassPropertyDescriptor: + def __init__(self, fget, fset=None): + self.fget = fget + self.fset = fset + + def __get__(self, obj, klass=None): + if klass is None: + klass = type(obj) + return self.fget.__get__(obj, klass)() + + def __set__(self, obj, value): + if not self.fset: + raise AttributeError("can't set attribute") + type_ = type(obj) + return self.fset.__get__(obj, type_)(value) + + def setter(self, func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + self.fset = func + return self + + +def classproperty(func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + + return ClassPropertyDescriptor(func) + + +AUDIO_FILE_EXT_ALIASES = { + "m4a": "mp4", + "wave": "wav", +} + +WavSubChunk = namedtuple("WavSubChunk", ["id", "position", "size"]) +WavData = namedtuple( + "WavData", ["audio_format", "channels", "sample_rate", "bits_per_sample", "raw_data"] +) + + +def extract_wav_headers(data): + # def search_subchunk(data, subchunk_id): + pos = 12 # The size of the RIFF chunk descriptor + subchunks = [] + while pos + 8 <= len(data) and len(subchunks) < 10: + subchunk_id = data[pos : pos + 4] + subchunk_size = struct.unpack_from(" 2**32: + raise CouldntDecodeError("Unable to process >4GB files") + + # Set the file size in the RIFF chunk descriptor + data[4:8] = struct.pack(" b"\x7f"[0]]) + old_bytes = struct.pack(pack_fmt, b0, b1, b2) + byte_buffer.write(old_bytes) + + self._data = byte_buffer.getvalue() + self.sample_width = 4 + self.frame_width = self.channels * self.sample_width + + super(_AudioSegment, self).__init__(*args, **kwargs) + + @property + def raw_data(self): + """Public access to the raw audio data as a bytestring.""" + return self._data + + def get_array_of_samples(self, array_type_override=None): + """Return the raw_data as an array of samples.""" + if array_type_override is None: + array_type_override = self.array_type + return array.array(array_type_override, self._data) + + @property + def array_type(self): + return get_array_type(self.sample_width * 8) + + def __len__(self): + """Return the length of this audio segment in milliseconds.""" + return round(1000 * (self.frame_count() / self.frame_rate)) + + def __eq__(self, other): + try: + return self._data == other._data + except Exception: + return False + + def __hash__(self): + return hash(_AudioSegment) ^ hash( + (self.channels, self.frame_rate, self.sample_width, self._data) + ) + + def __ne__(self, other): + return not (self == other) + + def __iter__(self): + return (self[i] for i in xrange(len(self))) + + def __getitem__(self, millisecond): + if isinstance(millisecond, slice): + if millisecond.step: + return ( + self[i : i + millisecond.step] for i in xrange(*millisecond.indices(len(self))) + ) + + start = millisecond.start if millisecond.start is not None else 0 + end = millisecond.stop if millisecond.stop is not None else len(self) + + start = min(start, len(self)) + end = min(end, len(self)) + else: + start = millisecond + end = millisecond + 1 + + start = self._parse_position(start) * self.frame_width + end = self._parse_position(end) * self.frame_width + data = self._data[start:end] + + # ensure the output is as long as the requester is expecting + expected_length = end - start + missing_frames = (expected_length - len(data)) // self.frame_width + if missing_frames: + if missing_frames > self.frame_count(ms=2): + raise TooManyMissingFrames( + "You should never be filling in " # noqa: UP031 + " more than 2 ms with silence here, " + "missing frames: %s" % missing_frames + ) + silence = audioop.mul(data[: self.frame_width], self.sample_width, 0) + data += silence * missing_frames + + return self._spawn(data) + + def get_sample_slice(self, start_sample=None, end_sample=None): + """Get a section of the audio segment by sample index. + + NOTE: Negative indices do *not* address samples backword + from the end of the audio segment like a python list. + This is intentional. + """ + max_val = int(self.frame_count()) + + def bounded(val, default): + if val is None: + return default + if val < 0: + return 0 + if val > max_val: + return max_val + return val + + start_i = bounded(start_sample, 0) * self.frame_width + end_i = bounded(end_sample, max_val) * self.frame_width + + data = self._data[start_i:end_i] + return self._spawn(data) + + def __add__(self, arg): + if isinstance(arg, _AudioSegment): + return self.append(arg, crossfade=0) + else: + return self.apply_gain(arg) + + def __radd__(self, rarg): + """Permit use of sum() builtin with an iterable of AudioSegments.""" + if rarg == 0: + return self + raise TypeError("Gains must be the second addend after the " "AudioSegment") + + def __sub__(self, arg): + if isinstance(arg, _AudioSegment): + raise TypeError("AudioSegment objects can't be subtracted from " "each other") + else: + return self.apply_gain(-arg) + + def __mul__(self, arg): + """If the argument is an AudioSegment, overlay the multiplied audio segment. + + If it's a number, just use the string multiply operation to repeat the + audio. + + The following would return an AudioSegment that contains the + audio of audio_seg eight times + + `audio_seg * 8` + """ + if isinstance(arg, _AudioSegment): + return self.overlay(arg, position=0, loop=True) + else: + return self._spawn(data=self._data * arg) + + def _spawn(self, data, overrides={}): # noqa: B006 + """Create a new audio segment using the metadata from the current one & the data passed in. + + Should be used whenever an AudioSegment is being returned by an operation that would alters + the current one, since AudioSegment objects are immutable. + """ + # accept lists of data chunks + if isinstance(data, list): + data = b"".join(data) + + if isinstance(data, array.array): + try: + data = data.tobytes() + except Exception: + data = data.tostring() + + # accept file-like objects + if hasattr(data, "read"): + if hasattr(data, "seek"): + data.seek(0) + data = data.read() + + metadata = { + "sample_width": self.sample_width, + "frame_rate": self.frame_rate, + "frame_width": self.frame_width, + "channels": self.channels, + } + metadata.update(overrides) + return self.__class__(data=data, metadata=metadata) + + @classmethod + def _sync(cls, *segs): + channels = max(seg.channels for seg in segs) + frame_rate = max(seg.frame_rate for seg in segs) + sample_width = max(seg.sample_width for seg in segs) + + return tuple( + seg.set_channels(channels).set_frame_rate(frame_rate).set_sample_width(sample_width) + for seg in segs + ) + + def _parse_position(self, val): + if val < 0: + val = len(self) - abs(val) + val = self.frame_count(ms=len(self)) if val == float("inf") else self.frame_count(ms=val) + return int(val) + + @classmethod + def empty(cls): + return cls( + b"", metadata={"channels": 1, "sample_width": 1, "frame_rate": 1, "frame_width": 1} + ) + + @classmethod + def silent(cls, duration=1000, frame_rate=11025): + """Generate a silent audio segment. + + Duration specified in milliseconds (default duration: 1000ms, default frame_rate: 11025). + """ + frames = int(frame_rate * (duration / 1000.0)) + data = b"\0\0" * frames + return cls( + data, + metadata={"channels": 1, "sample_width": 2, "frame_rate": frame_rate, "frame_width": 2}, + ) + + @classmethod + def from_mono_audiosegments(cls, *mono_segments): + if not len(mono_segments): + raise ValueError("At least one AudioSegment instance is required") + + segs = cls._sync(*mono_segments) + + if segs[0].channels != 1: + raise ValueError( + "AudioSegment.from_mono_audiosegments requires all " + "arguments are mono AudioSegment instances" + ) + + channels = len(segs) + sample_width = segs[0].sample_width + frame_rate = segs[0].frame_rate + + frame_count = max(int(seg.frame_count()) for seg in segs) + data = array.array(segs[0].array_type, b"\0" * (frame_count * sample_width * channels)) + + for i, seg in enumerate(segs): + data[i::channels] = seg.get_array_of_samples() + + return cls( + data, + channels=channels, + sample_width=sample_width, + frame_rate=frame_rate, + ) + + @classmethod + def from_file_using_temporary_files( + cls, + file, + format=None, + codec=None, + parameters=None, + start_second=None, + duration=None, + **kwargs, + ): + orig_file = file + file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False) + + if format: + format = format.lower() + format = AUDIO_FILE_EXT_ALIASES.get(format, format) + + def is_format(f): + f = f.lower() + if format == f: + return True + if isinstance(orig_file, basestring): + return orig_file.lower().endswith(f".{f}") + if isinstance(orig_file, bytes): + return orig_file.lower().endswith((f".{f}").encode()) + return False + + if is_format("wav"): + try: + obj = cls._from_safe_wav(file) + if close_file: + file.close() + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[start_second * 1000 :] + elif start_second is None and duration is not None: + return obj[: duration * 1000] + else: + return obj[start_second * 1000 : (start_second + duration) * 1000] + except Exception: + file.seek(0) + elif is_format("raw") or is_format("pcm"): + sample_width = kwargs["sample_width"] + frame_rate = kwargs["frame_rate"] + channels = kwargs["channels"] + metadata = { + "sample_width": sample_width, + "frame_rate": frame_rate, + "channels": channels, + "frame_width": channels * sample_width, + } + obj = cls(data=file.read(), metadata=metadata) + if close_file: + file.close() + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[start_second * 1000 :] + elif start_second is None and duration is not None: + return obj[: duration * 1000] + else: + return obj[start_second * 1000 : (start_second + duration) * 1000] + + input_file = NamedTemporaryFile(mode="wb", delete=False) + try: + input_file.write(file.read()) + except OSError: + input_file.flush() + input_file.close() + input_file = NamedTemporaryFile(mode="wb", delete=False, buffering=2**31 - 1) + if close_file: + file.close() + close_file = True + file = open(orig_file, buffering=2**13 - 1, mode="rb") + reader = file.read(2**31 - 1) + while reader: + input_file.write(reader) + reader = file.read(2**31 - 1) + input_file.flush() + if close_file: + file.close() + + output = NamedTemporaryFile(mode="rb", delete=False) + + conversion_command = [ + cls.converter, + "-y", # always overwrite existing files + ] + + # If format is not defined + # ffmpeg/avconv will detect it automatically + if format: + conversion_command += ["-f", format] + + if codec: + # force audio decoder + conversion_command += ["-acodec", codec] + + conversion_command += [ + "-i", + input_file.name, # input_file options (filename last) + "-vn", # Drop any video streams if there are any + "-f", + "wav", # output options (filename last) + ] + + if start_second is not None: + conversion_command += ["-ss", str(start_second)] + + if duration is not None: + conversion_command += ["-t", str(duration)] + + conversion_command += [output.name] + + if parameters is not None: + # extend arguments with arbitrary set + conversion_command.extend(parameters) + + log_conversion(conversion_command) + + with open(os.devnull, "rb") as devnull: + # PATCHED + p = promptless_Popen( + conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + p_out, p_err = p.communicate() + + log_subprocess_output(p_out) + log_subprocess_output(p_err) + + try: + if p.returncode != 0: + raise CouldntDecodeError( + "Decoding failed. ffmpeg returned error code: " # noqa: UP030 + "{0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format( + p.returncode, p_err.decode(errors="ignore") + ) + ) + obj = cls._from_safe_wav(output) + finally: + input_file.close() + output.close() + os.unlink(input_file.name) + os.unlink(output.name) + + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[0:] + elif start_second is None and duration is not None: + return obj[: duration * 1000] + else: + return obj[0 : duration * 1000] + + @classmethod + def from_file( + cls, + file, + format=None, + codec=None, + parameters=None, + start_second=None, + duration=None, + **kwargs, + ): + orig_file = file + try: + filename = fsdecode(file) + except TypeError: + filename = None + file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False) + + if format: + format = format.lower() + format = AUDIO_FILE_EXT_ALIASES.get(format, format) + + def is_format(f): + f = f.lower() + if format == f: + return True + + if filename: + return filename.lower().endswith(f".{f}") + + return False + + if is_format("wav"): + try: + if start_second is None and duration is None: + return cls._from_safe_wav(file) + elif start_second is not None and duration is None: + return cls._from_safe_wav(file)[start_second * 1000 :] + elif start_second is None and duration is not None: + return cls._from_safe_wav(file)[: duration * 1000] + else: + return cls._from_safe_wav(file)[ + start_second * 1000 : (start_second + duration) * 1000 + ] + except Exception: + file.seek(0) + elif is_format("raw") or is_format("pcm"): + sample_width = kwargs["sample_width"] + frame_rate = kwargs["frame_rate"] + channels = kwargs["channels"] + metadata = { + "sample_width": sample_width, + "frame_rate": frame_rate, + "channels": channels, + "frame_width": channels * sample_width, + } + if start_second is None and duration is None: + return cls(data=file.read(), metadata=metadata) + elif start_second is not None and duration is None: + return cls(data=file.read(), metadata=metadata)[start_second * 1000 :] + elif start_second is None and duration is not None: + return cls(data=file.read(), metadata=metadata)[: duration * 1000] + else: + return cls(data=file.read(), metadata=metadata)[ + start_second * 1000 : (start_second + duration) * 1000 + ] + + conversion_command = [ + cls.converter, + "-y", # always overwrite existing files + ] + + # If format is not defined + # ffmpeg/avconv will detect it automatically + if format: + conversion_command += ["-f", format] + + if codec: + # force audio decoder + conversion_command += ["-acodec", codec] + + read_ahead_limit = kwargs.get("read_ahead_limit", -1) + if filename: + conversion_command += ["-i", filename] + stdin_parameter = None + stdin_data = None + else: + if cls.converter == "ffmpeg": + conversion_command += [ + "-read_ahead_limit", + str(read_ahead_limit), + "-i", + "cache:pipe:0", + ] + else: + conversion_command += ["-i", "-"] + stdin_parameter = subprocess.PIPE + stdin_data = file.read() + + if codec: + info = None + else: + # PATCHED + try: + info = _mediainfo_json(orig_file, read_ahead_limit=read_ahead_limit) + except FileNotFoundError: + raise ChildProcessError + if info: + audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"] + # This is a workaround for some ffprobe versions that always say + # that mp3/mp4/aac/webm/ogg files contain fltp samples + audio_codec = audio_streams[0].get("codec_name") + if audio_streams[0].get("sample_fmt") == "fltp" and audio_codec in [ + "mp3", + "mp4", + "aac", + "webm", + "ogg", + ]: + bits_per_sample = 16 + else: + bits_per_sample = audio_streams[0]["bits_per_sample"] + acodec = "pcm_u8" if bits_per_sample == 8 else "pcm_s%dle" % bits_per_sample + + conversion_command += ["-acodec", acodec] + + conversion_command += [ + "-vn", # Drop any video streams if there are any + "-f", + "wav", # output options (filename last) + ] + + if start_second is not None: + conversion_command += ["-ss", str(start_second)] + + if duration is not None: + conversion_command += ["-t", str(duration)] + + conversion_command += ["-"] + + if parameters is not None: + # extend arguments with arbitrary set + conversion_command.extend(parameters) + + log_conversion(conversion_command) + + # PATCHED + p = promptless_Popen( + conversion_command, + stdin=stdin_parameter, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + p_out, p_err = p.communicate(input=stdin_data) + + if p.returncode != 0 or len(p_out) == 0: + if close_file: + file.close() + raise CouldntDecodeError( + "Decoding failed. ffmpeg returned error code: " # noqa: UP030 + "{0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format( + p.returncode, p_err.decode(errors="ignore") + ) + ) + + p_out = bytearray(p_out) + fix_wav_headers(p_out) + p_out = bytes(p_out) + obj = cls(p_out) + + if close_file: + file.close() + + if start_second is None and duration is None: + return obj + elif start_second is not None and duration is None: + return obj[0:] + elif start_second is None and duration is not None: + return obj[: duration * 1000] + else: + return obj[0 : duration * 1000] + + @classmethod + def from_mp3(cls, file, parameters=None): + return cls.from_file(file, "mp3", parameters=parameters) + + @classmethod + def from_flv(cls, file, parameters=None): + return cls.from_file(file, "flv", parameters=parameters) + + @classmethod + def from_ogg(cls, file, parameters=None): + return cls.from_file(file, "ogg", parameters=parameters) + + @classmethod + def from_wav(cls, file, parameters=None): + return cls.from_file(file, "wav", parameters=parameters) + + @classmethod + def from_raw(cls, file, **kwargs): + return cls.from_file( + file, + "raw", + sample_width=kwargs["sample_width"], + frame_rate=kwargs["frame_rate"], + channels=kwargs["channels"], + ) + + @classmethod + def _from_safe_wav(cls, file): + file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False) + file.seek(0) + obj = cls(data=file) + if close_file: + file.close() + return obj + + def export( + self, + out_f=None, + format="mp3", + codec=None, + bitrate=None, + parameters=None, + tags=None, + id3v2_version="4", + cover=None, + ): + """Export an AudioSegment to a file with the given options. + + out_f (string): + Path to destination audio file. Also accepts os.PathLike objects on + python >= 3.6 + + format (string) + Format for destination audio file. + ('mp3', 'wav', 'raw', 'ogg' or other ffmpeg/avconv supported files) + + codec (string) + Codec used to encode the destination file. + + bitrate (string) + Bitrate used when encoding destination file. (64, 92, 128, 256, 312k...) + Each codec accepts different bitrate arguments so take a look at the + ffmpeg documentation for details (bitrate usually shown as -b, -ba or + -a:b). + + parameters (list of strings) + Aditional ffmpeg/avconv parameters + + tags (dict) + Set metadata information to destination files + usually used as tags. ({title='Song Title', artist='Song Artist'}) + + id3v2_version (string) + Set ID3v2 version for tags. (default: '4') + + cover (file) + Set cover for audio file from image file. (png or jpg) + """ + id3v2_allowed_versions = ["3", "4"] + + if format == "raw" and (codec is not None or parameters is not None): + raise AttributeError( + 'Can not invoke ffmpeg when export format is "raw"; ' + 'specify an ffmpeg raw format like format="s16le" instead ' + 'or call export(format="raw") with no codec or parameters' + ) + + out_f, _ = _fd_or_path_or_tempfile(out_f, "wb+") + out_f.seek(0) + + if format == "raw": + out_f.write(self._data) + out_f.seek(0) + return out_f + + # wav with no ffmpeg parameters can just be written directly to out_f + easy_wav = format == "wav" and codec is None and parameters is None + + data = out_f if easy_wav else NamedTemporaryFile(mode="wb", delete=False) + + pcm_for_wav = self._data + if self.sample_width == 1: + # convert to unsigned integers for wav + pcm_for_wav = audioop.bias(self._data, 1, 128) + + wave_data = wave.open(data, "wb") + wave_data.setnchannels(self.channels) + wave_data.setsampwidth(self.sample_width) + wave_data.setframerate(self.frame_rate) + # For some reason packing the wave header struct with + # a float in python 2 doesn't throw an exception + wave_data.setnframes(int(self.frame_count())) + wave_data.writeframesraw(pcm_for_wav) + wave_data.close() + + # for easy wav files, we're done (wav data is written directly to out_f) + if easy_wav: + out_f.seek(0) + return out_f + + output = NamedTemporaryFile(mode="w+b", delete=False) + + # build converter command to export + conversion_command = [ + self.converter, + "-y", # always overwrite existing files + "-f", + "wav", + "-i", + data.name, # input options (filename last) + ] + + if codec is None: + codec = self.DEFAULT_CODECS.get(format, None) + + if cover is not None: + if ( + cover.lower().endswith((".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")) + and format == "mp3" + ): + conversion_command.extend(["-i", cover, "-map", "0", "-map", "1", "-c:v", "mjpeg"]) + else: + raise AttributeError( + "Currently cover images are only supported by MP3 files. The allowed image " + "formats are: .tif, .jpg, .bmp, .jpeg and .png." + ) + + if codec is not None: + # force audio encoder + conversion_command.extend(["-acodec", codec]) + + if bitrate is not None: + conversion_command.extend(["-b:a", bitrate]) + + if parameters is not None: + # extend arguments with arbitrary set + conversion_command.extend(parameters) + + if tags is not None: + if not isinstance(tags, dict): + raise InvalidTag("Tags must be a dictionary.") + else: + # Extend converter command with tags + # print(tags) + for key, value in tags.items(): + conversion_command.extend(["-metadata", f"{key}={value}"]) + + if format == "mp3": + # set id3v2 tag version + if id3v2_version not in id3v2_allowed_versions: + raise InvalidID3TagVersion( + "id3v2_version not allowed, allowed versions: %s" # noqa: UP031 + % id3v2_allowed_versions + ) + conversion_command.extend(["-id3v2_version", id3v2_version]) + + if sys.platform == "darwin" and codec == "mp3": + conversion_command.extend(["-write_xing", "0"]) + + conversion_command.extend( + [ + "-f", + format, + output.name, # output options (filename last) + ] + ) + + log_conversion(conversion_command) + + # read stdin / write stdout + with open(os.devnull, "rb") as devnull: + # PATCHED + p = promptless_Popen( + conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + p_out, p_err = p.communicate() + + log_subprocess_output(p_out) + log_subprocess_output(p_err) + + try: + if p.returncode != 0: + raise CouldntEncodeError( + "Encoding failed. ffmpeg/avlib returned error code: " # noqa: UP030 + "{0}\n\nCommand:{1}\n\nOutput from ffmpeg/avlib:\n\n{2}".format( + p.returncode, conversion_command, p_err.decode(errors="ignore") + ) + ) + + output.seek(0) + out_f.write(output.read()) + + finally: + data.close() + output.close() + os.unlink(data.name) + os.unlink(output.name) + + out_f.seek(0) + return out_f + + def get_frame(self, index): + frame_start = index * self.frame_width + frame_end = frame_start + self.frame_width + return self._data[frame_start:frame_end] + + def frame_count(self, ms=None): + """Return the number of frames for the given number of milliseconds. + + If not specified, return the number of frames in the whole AudioSegment + """ + if ms is not None: + return ms * (self.frame_rate / 1000.0) + else: + return float(len(self._data) // self.frame_width) + + def set_sample_width(self, sample_width): + if sample_width == self.sample_width: + return self + + frame_width = self.channels * sample_width + + return self._spawn( + audioop.lin2lin(self._data, self.sample_width, sample_width), + overrides={"sample_width": sample_width, "frame_width": frame_width}, + ) + + def set_frame_rate(self, frame_rate): + if frame_rate == self.frame_rate: + return self + + if self._data: + converted, _ = audioop.ratecv( + self._data, self.sample_width, self.channels, self.frame_rate, frame_rate, None + ) + else: + converted = self._data + + return self._spawn(data=converted, overrides={"frame_rate": frame_rate}) + + def set_channels(self, channels): + if channels == self.channels: + return self + + if channels == 2 and self.channels == 1: + fn = audioop.tostereo + frame_width = self.frame_width * 2 + fac = 1 + converted = fn(self._data, self.sample_width, fac, fac) + elif channels == 1 and self.channels == 2: + fn = audioop.tomono + frame_width = self.frame_width // 2 + fac = 0.5 + converted = fn(self._data, self.sample_width, fac, fac) + elif channels == 1: + channels_data = [seg.get_array_of_samples() for seg in self.split_to_mono()] + frame_count = int(self.frame_count()) + converted = array.array( + channels_data[0].typecode, b"\0" * (frame_count * self.sample_width) + ) + for raw_channel_data in channels_data: + for i in range(frame_count): + converted[i] += raw_channel_data[i] // self.channels + frame_width = self.frame_width // self.channels + elif self.channels == 1: + dup_channels = [self for iChannel in range(channels)] + return _AudioSegment.from_mono_audiosegments(*dup_channels) + else: + raise ValueError( + "AudioSegment.set_channels only supports mono-to-multi channel " + "and multi-to-mono channel conversion" + ) + + return self._spawn( + data=converted, overrides={"channels": channels, "frame_width": frame_width} + ) + + def split_to_mono(self): + if self.channels == 1: + return [self] + + samples = self.get_array_of_samples() + + mono_channels = [] + for i in range(self.channels): + samples_for_current_channel = samples[i :: self.channels] + + try: + mono_data = samples_for_current_channel.tobytes() + except AttributeError: + mono_data = samples_for_current_channel.tostring() + + mono_channels.append( + self._spawn(mono_data, overrides={"channels": 1, "frame_width": self.sample_width}) + ) + + return mono_channels + + @property + def rms(self): + return audioop.rms(self._data, self.sample_width) + + @property + def dBFS(self): # noqa: N802 + rms = self.rms + if not rms: + return -float("infinity") + return ratio_to_db(self.rms / self.max_possible_amplitude) + + @property + def max(self): + return audioop.max(self._data, self.sample_width) + + @property + def max_possible_amplitude(self): + bits = self.sample_width * 8 + max_possible_val = 2**bits + + # since half is above 0 and half is below the max amplitude is divided + return max_possible_val / 2 + + @property + def max_dBFS(self): + return ratio_to_db(self.max, self.max_possible_amplitude) + + @property + def duration_seconds(self): + return self.frame_rate and self.frame_count() / self.frame_rate or 0.0 + + def get_dc_offset(self, channel=1): + """Return a value between -1.0 and 1.0 representing the DC offset of a channel. + + 1 for left, 2 for right. + """ + if not 1 <= channel <= 2: + raise ValueError("channel value must be 1 (left) or 2 (right)") + + if self.channels == 1: + data = self._data + elif channel == 1: + data = audioop.tomono(self._data, self.sample_width, 1, 0) + else: + data = audioop.tomono(self._data, self.sample_width, 0, 1) + + return float(audioop.avg(data, self.sample_width)) / self.max_possible_amplitude + + def remove_dc_offset(self, channel=None, offset=None): + """Remove DC offset of given channel. Calculates offset if it's not given. + + Offset values must be in range -1.0 to 1.0. If channel is None, removes + DC offset from all available channels. + """ + if channel and not 1 <= channel <= 2: + raise ValueError("channel value must be None, 1 (left) or 2 (right)") + + if offset and not -1.0 <= offset <= 1.0: + raise ValueError("offset value must be in range -1.0 to 1.0") + + if offset: + offset = int(round(offset * self.max_possible_amplitude)) + + def remove_data_dc(data, off): + if not off: + off = audioop.avg(data, self.sample_width) + return audioop.bias(data, self.sample_width, -off) + + if self.channels == 1: + return self._spawn(data=remove_data_dc(self._data, offset)) + + left_channel = audioop.tomono(self._data, self.sample_width, 1, 0) + right_channel = audioop.tomono(self._data, self.sample_width, 0, 1) + + if not channel or channel == 1: + left_channel = remove_data_dc(left_channel, offset) + + if not channel or channel == 2: + right_channel = remove_data_dc(right_channel, offset) + + left_channel = audioop.tostereo(left_channel, self.sample_width, 1, 0) + right_channel = audioop.tostereo(right_channel, self.sample_width, 0, 1) + + return self._spawn(data=audioop.add(left_channel, right_channel, self.sample_width)) + + def apply_gain(self, volume_change): + return self._spawn( + data=audioop.mul(self._data, self.sample_width, db_to_float(float(volume_change))) + ) + + def overlay(self, seg, position=0, loop=False, times=None, gain_during_overlay=None): + """Overlay the provided segment on to this segment. + + Starts at the specified position and uses the specified looping behavior. + + seg (AudioSegment): + The audio segment to overlay on to this one. + + position (optional int): + The position to start overlaying the provided segment in to this + one. + + loop (optional bool): + Loop seg as many times as necessary to match this segment's length. + Overrides loops param. + + times (optional int): + Loop seg the specified number of times or until it matches this + segment's length. 1 means once, 2 means twice, ... 0 would make the + call a no-op + gain_during_overlay (optional int): + Changes this segment's volume by the specified amount during the + duration of time that seg is overlaid on top of it. When negative, + this has the effect of 'ducking' the audio under the overlay. + """ + if loop: + # match loop=True's behavior with new times (count) mechanism. + times = -1 + elif times is None: + # no times specified, just once through + times = 1 + elif times == 0: + # it's a no-op, make a copy since we never mutate + return self._spawn(self._data) + + output = StringIO() + + seg1, seg2 = _AudioSegment._sync(self, seg) + sample_width = seg1.sample_width + spawn = seg1._spawn + + output.write(seg1[:position]._data) + + # drop down to the raw data + seg1 = seg1[position:]._data + seg2 = seg2._data + pos = 0 + seg1_len = len(seg1) + seg2_len = len(seg2) + while times: + remaining = max(0, seg1_len - pos) + if seg2_len >= remaining: + seg2 = seg2[:remaining] + seg2_len = remaining + # we've hit the end, we're done looping (if we were) and this + # is our last go-around + times = 1 + + if gain_during_overlay: + seg1_overlaid = seg1[pos : pos + seg2_len] + seg1_adjusted_gain = audioop.mul( + seg1_overlaid, self.sample_width, db_to_float(float(gain_during_overlay)) + ) + output.write(audioop.add(seg1_adjusted_gain, seg2, sample_width)) + else: + output.write(audioop.add(seg1[pos : pos + seg2_len], seg2, sample_width)) + pos += seg2_len + + # dec times to break our while loop (eventually) + times -= 1 + + output.write(seg1[pos:]) + + return spawn(data=output) + + def append(self, seg, crossfade=100): + seg1, seg2 = _AudioSegment._sync(self, seg) + + if not crossfade: + return seg1._spawn(seg1._data + seg2._data) + elif crossfade > len(self): + raise ValueError( + f"Crossfade is longer than the original AudioSegment ({crossfade}ms > {len(self)}ms)" + ) + elif crossfade > len(seg): + raise ValueError( + f"Crossfade is longer than the appended AudioSegment ({crossfade}ms > {len(seg)}ms)" + ) + + xf = seg1[-crossfade:].fade(to_gain=-120, start=0, end=float("inf")) + xf *= seg2[:crossfade].fade(from_gain=-120, start=0, end=float("inf")) + + output = BytesIO() + + output.write(seg1[:-crossfade]._data) + output.write(xf._data) + output.write(seg2[crossfade:]._data) + + output.seek(0) + obj = seg1._spawn(data=output) + output.close() + return obj + + def fade(self, to_gain=0, from_gain=0, start=None, end=None, duration=None): + """Fade the volume of this audio segment. + + to_gain (float): + resulting volume_change in db + + start (int): + default = beginning of the segment + when in this segment to start fading in milliseconds + + end (int): + default = end of the segment + when in this segment to start fading in milliseconds + + duration (int): + default = until the end of the audio segment + the duration of the fade + """ + if None not in [duration, end, start]: + raise TypeError( + 'Only two of the three arguments, "start", ' + '"end", and "duration" may be specified' + ) + + # no fade == the same audio + if to_gain == 0 and from_gain == 0: + return self + + start = min(len(self), start) if start is not None else None + end = min(len(self), end) if end is not None else None + + if start is not None and start < 0: + start += len(self) + if end is not None and end < 0: + end += len(self) + + if duration is not None and duration < 0: + raise InvalidDuration("duration must be a positive integer") + + if duration: + if start is not None: + end = start + duration + elif end is not None: + start = end - duration + else: + duration = end - start + + from_power = db_to_float(from_gain) + + output = [] + + # original data - up until the crossfade portion, as is + before_fade = self[:start]._data + if from_gain != 0: + before_fade = audioop.mul(before_fade, self.sample_width, from_power) + output.append(before_fade) + + gain_delta = db_to_float(to_gain) - from_power + + # fades longer than 100ms can use coarse fading (one gain step per ms), + # shorter fades will have audible clicks so they use precise fading + # (one gain step per sample) + if duration > 100: + scale_step = gain_delta / duration + + for i in range(duration): + volume_change = from_power + (scale_step * i) + chunk = self[start + i] + chunk = audioop.mul(chunk._data, self.sample_width, volume_change) + + output.append(chunk) + else: + start_frame = self.frame_count(ms=start) + end_frame = self.frame_count(ms=end) + fade_frames = end_frame - start_frame + scale_step = gain_delta / fade_frames + + for i in range(int(fade_frames)): + volume_change = from_power + (scale_step * i) + sample = self.get_frame(int(start_frame + i)) + sample = audioop.mul(sample, self.sample_width, volume_change) + + output.append(sample) + + # original data after the crossfade portion, at the new volume + after_fade = self[end:]._data + if to_gain != 0: + after_fade = audioop.mul(after_fade, self.sample_width, db_to_float(to_gain)) + output.append(after_fade) + + return self._spawn(data=output) + + def fade_out(self, duration): + return self.fade(to_gain=-120, duration=duration, end=float("inf")) + + def fade_in(self, duration): + return self.fade(from_gain=-120, duration=duration, start=0) + + def reverse(self): + return self._spawn(data=audioop.reverse(self._data, self.sample_width)) + + def _repr_html_(self): + src = """ + + """ + fh = self.export() + data = base64.b64encode(fh.read()).decode("ascii") + return src.format(base64=data) diff --git a/tagstudio/src/qt/helpers/vendored/pydub/utils.py b/tagstudio/src/qt/helpers/vendored/pydub/utils.py new file mode 100644 index 00000000..5c9e3c4b --- /dev/null +++ b/tagstudio/src/qt/helpers/vendored/pydub/utils.py @@ -0,0 +1,89 @@ +import json +import re +import subprocess + +from pydub.utils import ( + _fd_or_path_or_tempfile, + fsdecode, + get_extra_info, + get_prober_name, +) +from src.qt.helpers.silent_popen import promptless_Popen + + +def _mediainfo_json(filepath, read_ahead_limit=-1): + """Return json dictionary with media info(codec, duration, size, bitrate...) from filepath.""" + prober = get_prober_name() + command_args = [ + "-v", + "info", + "-show_format", + "-show_streams", + ] + try: + command_args += [fsdecode(filepath)] + stdin_parameter = None + stdin_data = None + except TypeError: + if prober == "ffprobe": + command_args += ["-read_ahead_limit", str(read_ahead_limit), "cache:pipe:0"] + else: + command_args += ["-"] + stdin_parameter = subprocess.PIPE + file, close_file = _fd_or_path_or_tempfile(filepath, "rb", tempfile=False) + file.seek(0) + stdin_data = file.read() + if close_file: + file.close() + + command = [prober, "-of", "json"] + command_args + # PATCHED + res = promptless_Popen( + command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + output, stderr = res.communicate(input=stdin_data) + output = output.decode("utf-8", "ignore") + stderr = stderr.decode("utf-8", "ignore") + + try: + info = json.loads(output) + except json.decoder.JSONDecodeError: + # If ffprobe didn't give any information, just return it + # (for example, because the file doesn't exist) + return None + if not info: + return info + + extra_info = get_extra_info(stderr) + + audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"] + if len(audio_streams) == 0: + return info + + # We just operate on the first audio stream in case there are more + stream = audio_streams[0] + + def set_property(stream, prop, value): + if prop not in stream or stream[prop] == 0: + stream[prop] = value + + for token in extra_info[stream["index"]]: + m = re.match(r"([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$", token) + m2 = re.match(r"([su]([0-9]{1,2})p?)( \(default\))?$", token) + if m: + set_property(stream, "sample_fmt", m.group(1)) + set_property(stream, "bits_per_sample", int(m.group(2))) + set_property(stream, "bits_per_raw_sample", int(m.group(3))) + elif m2: + set_property(stream, "sample_fmt", m2.group(1)) + set_property(stream, "bits_per_sample", int(m2.group(2))) + set_property(stream, "bits_per_raw_sample", int(m2.group(2))) + elif re.match(r"(flt)p?( \(default\))?$", token): + set_property(stream, "sample_fmt", token) + set_property(stream, "bits_per_sample", 32) + set_property(stream, "bits_per_raw_sample", 32) + elif re.match(r"(dbl)p?( \(default\))?$", token): + set_property(stream, "sample_fmt", token) + set_property(stream, "bits_per_sample", 64) + set_property(stream, "bits_per_raw_sample", 64) + return info diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index ce6b1e33..d3274c7e 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -36,7 +36,7 @@ class Ui_MainWindow(QMainWindow): def __init__(self, driver: "QtDriver", parent=None) -> None: super().__init__(parent) - self.driver = driver + self.driver: "QtDriver" = driver self.setupUi(self) # NOTE: These are old attempts to allow for a translucent/acrylic @@ -66,7 +66,7 @@ class Ui_MainWindow(QMainWindow): self.horizontalLayout = QHBoxLayout() self.horizontalLayout.setObjectName(u"horizontalLayout") - # ComboBox goup for search type and thumbnail size + # ComboBox group for search type and thumbnail size self.horizontalLayout_3 = QHBoxLayout() self.horizontalLayout_3.setObjectName("horizontalLayout_3") @@ -74,7 +74,7 @@ class Ui_MainWindow(QMainWindow): spacerItem = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) self.horizontalLayout_3.addItem(spacerItem) - # Search type selector + # Search type selector self.comboBox_2 = QComboBox(self.centralwidget) self.comboBox_2.setMinimumSize(QSize(165, 0)) self.comboBox_2.setObjectName("comboBox_2") @@ -83,17 +83,17 @@ class Ui_MainWindow(QMainWindow): self.horizontalLayout_3.addWidget(self.comboBox_2) # Thumbnail Size placeholder - self.comboBox = QComboBox(self.centralwidget) - self.comboBox.setObjectName(u"comboBox") + self.thumb_size_combobox = QComboBox(self.centralwidget) + self.thumb_size_combobox.setObjectName(u"thumbSizeComboBox") sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth( - self.comboBox.sizePolicy().hasHeightForWidth()) - self.comboBox.setSizePolicy(sizePolicy) - self.comboBox.setMinimumWidth(128) - self.comboBox.setMaximumWidth(128) - self.horizontalLayout_3.addWidget(self.comboBox) + self.thumb_size_combobox.sizePolicy().hasHeightForWidth()) + self.thumb_size_combobox.setSizePolicy(sizePolicy) + self.thumb_size_combobox.setMinimumWidth(128) + self.thumb_size_combobox.setMaximumWidth(352) + self.horizontalLayout_3.addWidget(self.thumb_size_combobox) self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1) self.splitter = QSplitter() @@ -212,10 +212,10 @@ class Ui_MainWindow(QMainWindow): # Search type selector self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", "And (Includes All Tags)")) self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", "Or (Includes Any Tag)")) - self.comboBox.setCurrentText("") + self.thumb_size_combobox.setCurrentText("") # Thumbnail size selector - self.comboBox.setPlaceholderText( + self.thumb_size_combobox.setPlaceholderText( QCoreApplication.translate("MainWindow", u"Thumbnail Size", None)) # retranslateUi @@ -236,3 +236,4 @@ class Ui_MainWindow(QMainWindow): self.landing_widget.setHidden(True) self.landing_widget.set_status_label("") self.scrollArea.setHidden(False) + \ No newline at end of file diff --git a/tagstudio/src/qt/platform_strings.py b/tagstudio/src/qt/platform_strings.py new file mode 100644 index 00000000..9eda3ef8 --- /dev/null +++ b/tagstudio/src/qt/platform_strings.py @@ -0,0 +1,16 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +"""A collection of platform-dependant strings.""" + +import platform + + +class PlatformStrings: + open_file_str: str = "Open in file explorer" + + if platform.system() == "Windows": + open_file_str = "Open in Explorer" + elif platform.system() == "Darwin": + open_file_str = "Reveal in Finder" diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py index a9826e72..d9929e7b 100644 --- a/tagstudio/src/qt/resource_manager.py +++ b/tagstudio/src/qt/resource_manager.py @@ -7,6 +7,7 @@ from typing import Any import structlog import ujson +from PIL import Image logger = structlog.get_logger(__name__) @@ -17,6 +18,7 @@ class ResourceManager: _map: dict = {} _cache: dict[str, Any] = {} _initialized: bool = False + _res_folder: Path = Path(__file__).parents[2] def __init__(self) -> None: # Load JSON resource map @@ -26,6 +28,21 @@ class ResourceManager: logger.info("resources registered", count=len(ResourceManager._map.items())) ResourceManager._initialized = True + @staticmethod + def get_path(id: str) -> Path | None: + """Get a resource's path from the ResourceManager. + + Args: + id (str): The name of the resource. + + Returns: + Path: The resource path if found, else None. + """ + res: dict = ResourceManager._map.get(id) + if res: + return ResourceManager._res_folder / "resources" / res.get("path") + return None + def get(self, id: str) -> Any: """Get a resource from the ResourceManager. @@ -43,19 +60,29 @@ class ResourceManager: return cached_res else: res: dict = ResourceManager._map.get(id) - if res.get("mode") in ["r", "rb"]: - with open( - (Path(__file__).parents[2] / "resources" / res.get("path")), - res.get("mode"), - ) as f: - data = f.read() - if res.get("mode") == "rb": - data = bytes(data) - ResourceManager._cache[id] = data + if not res: + return None + try: + if res.get("mode") in ["r", "rb"]: + with open( + (ResourceManager._res_folder / "resources" / res.get("path")), + res.get("mode"), + ) as f: + data = f.read() + if res.get("mode") == "rb": + data = bytes(data) + ResourceManager._cache[id] = data + return data + elif res and res.get("mode") == "pil": + data = Image.open(ResourceManager._res_folder / "resources" / res.get("path")) return data - elif res.get("mode") in ["qt"]: - # TODO: Qt resource loading logic - pass + elif res.get("mode") in ["qt"]: + # TODO: Qt resource loading logic + pass + except FileNotFoundError: + path: Path = ResourceManager._res_folder / "resources" / res.get("path") + logger.error("[ResourceManager][ERROR]: Could not find resource: ", path) + return None def __getattr__(self, __name: str) -> Any: attr = self.get(__name) diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json index 1f8663d3..e5857909 100644 --- a/tagstudio/src/qt/resources.json +++ b/tagstudio/src/qt/resources.json @@ -14,5 +14,85 @@ "volume_mute_icon": { "path": "qt/images/volume_mute.svg", "mode": "rb" + }, + "broken_link_icon": { + "path": "qt/images/broken_link_icon.png", + "mode": "pil" + }, + "adobe_illustrator": { + "path": "qt/images/file_icons/adobe_illustrator.png", + "mode": "pil" + }, + "adobe_photoshop": { + "path": "qt/images/file_icons/adobe_photoshop.png", + "mode": "pil" + }, + "affinity_photo": { + "path": "qt/images/file_icons/affinity_photo.png", + "mode": "pil" + }, + "audio": { + "path": "qt/images/file_icons/audio.png", + "mode": "pil" + }, + "blender": { + "path": "qt/images/file_icons/blender.png", + "mode": "pil" + }, + "document": { + "path": "qt/images/file_icons/document.png", + "mode": "pil" + }, + "file_generic": { + "path": "qt/images/file_icons/file_generic.png", + "mode": "pil" + }, + "font": { + "path": "qt/images/file_icons/font.png", + "mode": "pil" + }, + "image": { + "path": "qt/images/file_icons/image.png", + "mode": "pil" + }, + "image_vector": { + "path": "qt/images/file_icons/image_vector.png", + "mode": "pil" + }, + "material": { + "path": "qt/images/file_icons/material.png", + "mode": "pil" + }, + "model": { + "path": "qt/images/file_icons/model.png", + "mode": "pil" + }, + "presentation": { + "path": "qt/images/file_icons/presentation.png", + "mode": "pil" + }, + "program": { + "path": "qt/images/file_icons/program.png", + "mode": "pil" + }, + "spreadsheet": { + "path": "qt/images/file_icons/spreadsheet.png", + "mode": "pil" + }, + "text": { + "path": "qt/images/file_icons/text.png", + "mode": "pil" + }, + "video": { + "path": "qt/images/file_icons/video.png", + "mode": "pil" + }, + "thumb_loading": { + "path": "qt/images/thumb_loading.png", + "mode": "pil" + }, + "placeholder_mp4": { + "path": "qt/videos/placeholder.mp4", + "mode": "rb" } } diff --git a/tagstudio/src/qt/resources_rc.py b/tagstudio/src/qt/resources_rc.py index f11bf7fb..c2d3874d 100644 --- a/tagstudio/src/qt/resources_rc.py +++ b/tagstudio/src/qt/resources_rc.py @@ -1,6 +1,6 @@ # Resource object code (Python 3) # Created by: object code -# Created by: The Resource Compiler for Qt version 6.6.3 +# Created by: The Resource Compiler for Qt version 6.7.1 # WARNING! All changes made in this file will be lost! from PySide6 import QtCore @@ -16240,7 +16240,7 @@ qt_resource_struct = b"\ \x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ \x00\x00\x01\x8a\xfb\xc6\x86\xda\ \x00\x00\x00H\x00\x00\x00\x00\x00\x01\x00\x00\x22\x83\ -\x00\x00\x01\x8e\xfd%\xc3\xc7\ +\x00\x00\x01\x92\x0cdgU\ " def qInitResources(): diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 9490e783..e80aa84e 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -453,7 +453,13 @@ class QtDriver(DriverMixin, QObject): str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf") ) - self.thumb_size = 128 + self.thumb_sizes: list[tuple[str, int]] = [ + ("Extra Large Thumbnails", 256), + ("Large Thumbnails", 192), + ("Medium Thumbnails", 128), + ("Small Thumbnails", 96), + ("Mini Thumbnails", 76), + ] self.item_thumbs: list[ItemThumb] = [] self.thumb_renderers: list[ThumbRenderer] = [] self.filter = FilterState() @@ -488,26 +494,37 @@ class QtDriver(DriverMixin, QObject): def init_library_window(self): # self._init_landing_page() # Taken care of inside the widget now - self._init_thumb_grid() # TODO: Put this into its own method that copies the font file(s) into memory # so the resource isn't being used, then store the specific size variations # in a global dict for methods to access for different DPIs. # adj_font_size = math.floor(12 * self.main_window.devicePixelRatio()) + # Search Button search_button: QPushButton = self.main_window.searchButton search_button.clicked.connect( lambda: self.filter_items(FilterState(query=self.main_window.searchField.text())) ) + # Search Field search_field: QLineEdit = self.main_window.searchField search_field.returnPressed.connect( # TODO - parse search field for filters lambda: self.filter_items(FilterState(query=self.main_window.searchField.text())) ) + # Search Type Selector search_type_selector: QComboBox = self.main_window.comboBox_2 search_type_selector.currentIndexChanged.connect( lambda: self.set_search_type(SearchMode(search_type_selector.currentIndex())) ) + # Thumbnail Size ComboBox + thumb_size_combobox: QComboBox = self.main_window.thumb_size_combobox + for size in self.thumb_sizes: + thumb_size_combobox.addItem(size[0]) + thumb_size_combobox.setCurrentIndex(2) # Default: Medium + thumb_size_combobox.currentIndexChanged.connect( + lambda: self.thumb_size_callback(thumb_size_combobox.currentIndex()) + ) + self._init_thumb_grid() back_button: QPushButton = self.main_window.backButton back_button.clicked.connect(lambda: self.page_move(-1)) @@ -802,6 +819,34 @@ class QtDriver(DriverMixin, QObject): content=strip_web_protocol(field.value), ) + def thumb_size_callback(self, index: int): + """Perform actions needed when the thumbnail size selection is changed. + + Args: + index (int): The index of the item_thumbs/ComboBox list to use. + """ + spacing_divisor: int = 10 + min_spacing: int = 12 + # Index 2 is the default (Medium) + if index < len(self.thumb_sizes) and index >= 0: + self.thumb_size = self.thumb_sizes[index][1] + else: + logger.error(f"ERROR: Invalid thumbnail size index ({index}). Defaulting to 128px.") + self.thumb_size = 128 + + self.update_thumbs() + blank_icon: QIcon = QIcon() + for it in self.item_thumbs: + it.thumb_button.setIcon(blank_icon) + it.resize(self.thumb_size, self.thumb_size) + it.thumb_size = (self.thumb_size, self.thumb_size) + it.setMinimumSize(self.thumb_size, self.thumb_size) + it.setMaximumSize(self.thumb_size, self.thumb_size) + it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size) + self.flow_container.layout().setSpacing( + min(self.thumb_size // spacing_divisor, min_spacing) + ) + def mouse_navigation(self, event: QMouseEvent): # print(event.button()) if event.button() == Qt.MouseButton.ForwardButton: diff --git a/tagstudio/src/qt/widgets/collage_icon.py b/tagstudio/src/qt/widgets/collage_icon.py index f3e1b364..e15cdd52 100644 --- a/tagstudio/src/qt/widgets/collage_icon.py +++ b/tagstudio/src/qt/widgets/collage_icon.py @@ -2,6 +2,7 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import math from pathlib import Path import cv2 @@ -12,9 +13,10 @@ from PySide6.QtCore import ( QObject, Signal, ) -from src.core.constants import DOC_TYPES, IMAGE_TYPES, VIDEO_TYPES from src.core.library import Library from src.core.library.alchemy.fields import _FieldID +from src.core.media_types import MediaCategories +from src.qt.helpers.file_tester import is_readable_video logger = structlog.get_logger(__name__) @@ -74,7 +76,8 @@ class CollageIconRenderer(QObject): color=self.get_file_color(filepath.suffix.lower()), ) - if filepath.suffix.lower() in IMAGE_TYPES: + ext: str = filepath.suffix.lower() + if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES): try: with Image.open(str(self.lib.library_dir / entry.path)) as pic: if keep_aspect: @@ -84,23 +87,34 @@ class CollageIconRenderer(QObject): 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 DecompressionBombError: - logger.exception("One of the images was too big", entry=entry.path) - elif filepath.suffix.lower() in VIDEO_TYPES: - video = cv2.VideoCapture(str(filepath)) + 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() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) + # 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: @@ -109,7 +123,6 @@ class CollageIconRenderer(QObject): pic = pic.resize(size) if data_tint_mode and color: pic = ImageChops.hard_light(pic, Image.new("RGB", size, color)) - # collage.paste(pic, (y*thumb_size, x*thumb_size)) self.rendered.emit(pic) except (UnidentifiedImageError, FileNotFoundError): logger.error("Couldn't read entry", entry=entry.path) @@ -130,13 +143,13 @@ class CollageIconRenderer(QObject): self.done.emit() def get_file_color(self, ext: str): - if ext.lower().replace(".", "", 1) == "gif": + if ext.lower() == "gif": return "\033[93m" - if ext.lower().replace(".", "", 1) in IMAGE_TYPES: + if MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES): return "\033[37m" - elif ext.lower().replace(".", "", 1) in VIDEO_TYPES: + elif MediaCategories.is_ext_in_category(ext, MediaCategories.VIDEO_TYPES): return "\033[96m" - elif ext.lower().replace(".", "", 1) in DOC_TYPES: + elif MediaCategories.is_ext_in_category(ext, MediaCategories.PLAINTEXT_TYPES): return "\033[92m" else: return "\033[97m" diff --git a/tagstudio/src/qt/widgets/item_thumb.py b/tagstudio/src/qt/widgets/item_thumb.py index 005cf1b0..da82a19d 100644 --- a/tagstudio/src/qt/widgets/item_thumb.py +++ b/tagstudio/src/qt/widgets/item_thumb.py @@ -21,17 +21,16 @@ from PySide6.QtWidgets import ( QWidget, ) from src.core.constants import ( - AUDIO_TYPES, - IMAGE_TYPES, TAG_ARCHIVED, TAG_FAVORITE, - VIDEO_TYPES, ) from src.core.library import Entry, ItemType, Library from src.core.library.alchemy.enums import FilterState from src.core.library.alchemy.fields import _FieldID +from src.core.media_types import MediaCategories, MediaType from src.qt.flowlayout import FlowWidget from src.qt.helpers.file_opener import FileOpenerHelper +from src.qt.platform_strings import PlatformStrings from src.qt.widgets.thumb_button import ThumbButton from src.qt.widgets.thumb_renderer import ThumbRenderer @@ -88,6 +87,7 @@ class ItemThumb(FlowWidget): small_text_style = ( "background-color:rgba(0, 0, 0, 192);" + "color:#FFFFFF;" "font-family:Oxanium;" "font-weight:bold;" "font-size:12px;" @@ -100,6 +100,7 @@ class ItemThumb(FlowWidget): med_text_style = ( "background-color:rgba(0, 0, 0, 192);" + "color:#FFFFFF;" "font-family:Oxanium;" "font-weight:bold;" "font-size:18px;" @@ -198,7 +199,7 @@ class ItemThumb(FlowWidget): self.opener = FileOpenerHelper("") open_file_action = QAction("Open file", self) open_file_action.triggered.connect(self.opener.open_file) - open_explorer_action = QAction("Open file in explorer", self) + open_explorer_action = QAction(PlatformStrings.open_file_str, self) open_explorer_action.triggered.connect(self.opener.open_explorer) self.thumb_button.addAction(open_file_action) self.thumb_button.addAction(open_explorer_action) @@ -330,10 +331,26 @@ class ItemThumb(FlowWidget): def set_extension(self, ext: str) -> None: if ext and ext.startswith(".") is False: ext = "." + ext - if ext and ext not in IMAGE_TYPES or ext in [".gif", ".apng"]: + media_types: set[MediaType] = MediaCategories.get_types(ext) + if ( + ext + and not MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES) + or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES) + or MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_VECTOR_TYPES) + or MediaCategories.is_ext_in_category(ext, MediaCategories.ADOBE_PHOTOSHOP_TYPES) + or ext + in [ + ".apng", + ".avif", + ".exr", + ".gif", + ".jxl", + ".webp", + ] + ): self.ext_badge.setHidden(False) self.ext_badge.setText(ext.upper()[1:]) - if ext in VIDEO_TYPES + AUDIO_TYPES: + if MediaType.VIDEO in media_types or MediaType.AUDIO in media_types: self.count_badge.setHidden(False) else: if self.mode == ItemType.ENTRY: diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index f595644f..b38fc6c1 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -1,6 +1,8 @@ # Copyright (C) 2024 Travis Abendshien (CyanVoxel). # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import os +import platform import sys import time import typing @@ -12,10 +14,10 @@ import cv2 import rawpy import structlog from humanfriendly import format_size -from PIL import Image, UnidentifiedImageError +from PIL import Image, ImageFont, UnidentifiedImageError from PIL.Image import DecompressionBombError -from PySide6.QtCore import QSize, Qt, Signal -from PySide6.QtGui import QAction, QResizeEvent +from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt, Signal +from PySide6.QtGui import QAction, QGuiApplication, QMovie, QResizeEvent from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -28,7 +30,9 @@ from PySide6.QtWidgets import ( QVBoxLayout, QWidget, ) -from src.core.constants import IMAGE_TYPES, RAW_IMAGE_TYPES, TS_FOLDER_NAME, VIDEO_TYPES +from src.core.constants import ( + TS_FOLDER_NAME, +) from src.core.enums import SettingItems, Theme from src.core.library.alchemy.enums import FilterState from src.core.library.alchemy.fields import ( @@ -40,9 +44,13 @@ from src.core.library.alchemy.fields import ( _FieldID, ) from src.core.library.alchemy.library import Library +from src.core.media_types import MediaCategories from src.qt.helpers.file_opener import FileOpenerHelper, FileOpenerLabel, open_file +from src.qt.helpers.file_tester import is_readable_video from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper +from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle from src.qt.modals.add_field import AddFieldModal +from src.qt.platform_strings import PlatformStrings from src.qt.widgets.fields import FieldContainer from src.qt.widgets.panel import PanelModal from src.qt.widgets.tag_box import TagBoxWidget @@ -80,8 +88,6 @@ class PreviewPanel(QWidget): self.driver: QtDriver = driver self.initialized = False self.is_open: bool = False - # self.filepath = None - # self.item = None # DEPRECATED, USE self.selected self.common_fields: list = [] self.mixed_fields: list = [] self.selected: list[int] = [] # New way of tracking items @@ -91,20 +97,55 @@ class PreviewPanel(QWidget): self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 + self.label_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_DARK_LABEL.value + ) + self.panel_bg_color = ( + Theme.COLOR_BG_DARK.value + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Theme.COLOR_BG_LIGHT.value + ) + self.image_container = QWidget() image_layout = QHBoxLayout(self.image_container) image_layout.setContentsMargins(0, 0, 0, 0) + file_label_style = "font-size: 12px" + properties_style = ( + f"background-color:{self.label_bg_color};" + "color:#FFFFFF;" + "font-family:Oxanium;" + "font-weight:bold;" + "font-size:12px;" + "border-radius:3px;" + "padding-top: 4px;" + "padding-right: 1px;" + "padding-bottom: 1px;" + "padding-left: 1px;" + ) + date_style = "font-size:12px;" + self.open_file_action = QAction("Open file", self) - self.open_explorer_action = QAction("Open file in explorer", self) + self.open_explorer_action = QAction(PlatformStrings.open_file_str, self) self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) self.preview_img.setFlat(True) self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.preview_img.addAction(self.open_file_action) self.preview_img.addAction(self.open_explorer_action) + + self.preview_gif = QLabel() + self.preview_gif.setMinimumSize(*self.img_button_size) + self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor) + self.preview_gif.addAction(self.open_file_action) + self.preview_gif.addAction(self.open_explorer_action) + self.preview_gif.hide() + self.gif_buffer: QBuffer = QBuffer() + self.preview_vid = VideoPlayer(driver) self.preview_vid.hide() self.thumb_renderer = ThumbRenderer() @@ -124,31 +165,29 @@ class PreviewPanel(QWidget): image_layout.addWidget(self.preview_img) image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) + image_layout.addWidget(self.preview_gif) + image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter) image_layout.addWidget(self.preview_vid) image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) self.image_container.setMinimumSize(*self.img_button_size) - self.file_label = FileOpenerLabel("Filename") + self.file_label = FileOpenerLabel("filename") + self.file_label.setTextFormat(Qt.TextFormat.RichText) self.file_label.setWordWrap(True) self.file_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - self.file_label.setStyleSheet("font-weight: bold; font-size: 12px") + self.file_label.setStyleSheet(file_label_style) - self.dimensions_label = QLabel("Dimensions") + self.date_created_label = QLabel("dateCreatedLabel") + self.date_created_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.date_created_label.setTextFormat(Qt.TextFormat.RichText) + self.date_created_label.setStyleSheet(date_style) + + self.date_modified_label = QLabel("dateModifiedLabel") + self.date_modified_label.setAlignment(Qt.AlignmentFlag.AlignLeft) + self.date_modified_label.setTextFormat(Qt.TextFormat.RichText) + self.date_modified_label.setStyleSheet(date_style) + + self.dimensions_label = QLabel("dimensionsLabel") self.dimensions_label.setWordWrap(True) - # self.dim_label.setTextInteractionFlags( - # Qt.TextInteractionFlag.TextSelectableByMouse) - - properties_style = ( - f"background-color:{Theme.COLOR_BG.value};" - f"font-family:Oxanium;" - f"font-weight:bold;" - f"font-size:12px;" - f"border-radius:6px;" - f"padding-top: 4px;" - f"padding-right: 1px;" - f"padding-bottom: 1px;" - f"padding-left: 1px;" - ) - self.dimensions_label.setStyleSheet(properties_style) self.scroll_layout = QVBoxLayout() @@ -175,15 +214,24 @@ class PreviewPanel(QWidget): # background and NOT the scroll container background, so that the # rounded corners are maintained when scrolling. I was unable to # find the right trick to only select that particular element. + scroll_area.setStyleSheet( "QWidget#entryScrollContainer{" - f"background: {Theme.COLOR_BG.value};" + f"background:{self.panel_bg_color};" "border-radius:6px;" "}" ) scroll_area.setWidget(scroll_container) + date_container = QWidget() + date_layout = QVBoxLayout(date_container) + date_layout.setContentsMargins(0, 2, 0, 0) + date_layout.setSpacing(0) + date_layout.addWidget(self.date_created_label) + date_layout.addWidget(self.date_modified_label) + info_layout.addWidget(self.file_label) + info_layout.addWidget(date_container) info_layout.addWidget(self.dimensions_label) info_layout.addWidget(scroll_area) @@ -326,11 +374,11 @@ class PreviewPanel(QWidget): return lambda: self.driver.open_library(Path(path)) button.clicked.connect(open_library_button_clicked(full_val)) - set_button_style(button) - button_remove = QPushButton("➖") + set_button_style(button, ["padding-left: 6px;", "text-align: left;"]) + button_remove = QPushButton("—") button_remove.setCursor(Qt.CursorShape.PointingHandCursor) - button_remove.setFixedWidth(30) - set_button_style(button_remove) + button_remove.setFixedWidth(24) + set_button_style(button_remove, ["font-weight:bold;", "text-align:center;"]) def remove_recent_library_clicked(key: str): return lambda: ( @@ -364,19 +412,14 @@ class PreviewPanel(QWidget): def update_image_size(self, size: tuple[int, int], ratio: float = None): if ratio: self.set_image_ratio(ratio) - # self.img_button_size = size - # logging.info(f'') - # self.preview_img.setMinimumSize(64,64) adj_width: float = size[0] adj_height: float = size[1] # Landscape if self.image_ratio > 1: - # logging.info('Landscape') adj_height = size[0] * (1 / self.image_ratio) # Portrait elif self.image_ratio <= 1: - # logging.info('Portrait') adj_width = size[1] * self.image_ratio if adj_width > size[0]: @@ -386,11 +429,6 @@ class PreviewPanel(QWidget): adj_width = adj_width * (size[1] / adj_height) adj_height = size[1] - # adj_width = min(adj_width, self.image_container.size().width()) - # adj_height = min(adj_width, self.image_container.size().height()) - - # self.preview_img.setMinimumSize(s) - # self.preview_img.setMaximumSize(s_max) adj_size = QSize(int(adj_width), int(adj_height)) self.img_button_size = (int(adj_width), int(adj_height)) self.preview_img.setMaximumSize(adj_size) @@ -398,7 +436,14 @@ class PreviewPanel(QWidget): self.preview_vid.resize_video(adj_size) self.preview_vid.setMaximumSize(adj_size) self.preview_vid.setMinimumSize(adj_size) - # self.preview_img.setMinimumSize(adj_size) + self.preview_gif.setMaximumSize(adj_size) + self.preview_gif.setMinimumSize(adj_size) + proxy_style = RoundedPixmapStyle(radius=8) + self.preview_gif.setStyle(proxy_style) + self.preview_vid.setStyle(proxy_style) + m = self.preview_gif.movie() + if m: + m.setScaledSize(adj_size) def place_add_field_button(self): self.scroll_layout.addWidget(self.afb_container) @@ -410,11 +455,7 @@ class PreviewPanel(QWidget): self.add_field_button.clicked.disconnect() self.add_field_modal.done.connect( - lambda items: ( - self.add_field_to_selected(items), - update_selected_entry(self.driver), - self.update_widgets(), - ) + lambda f: (self.add_field_to_selected(f), self.update_widgets()) ) self.add_field_modal.is_connected = True self.add_field_button.clicked.connect(self.add_field_modal.show) @@ -430,6 +471,32 @@ class PreviewPanel(QWidget): field_id=field_item.data(Qt.ItemDataRole.UserRole), ) + def update_date_label(self, filepath: Path | None = None) -> None: + """Update the "Date Created" and "Date Modified" file property labels.""" + if filepath and filepath.is_file(): + created: dt = None + if platform.system() == "Windows" or platform.system() == "Darwin": + created = dt.fromtimestamp(filepath.stat().st_birthtime) # type: ignore[attr-defined] + else: + created = dt.fromtimestamp(filepath.stat().st_ctime) + modified: dt = dt.fromtimestamp(filepath.stat().st_mtime) + self.date_created_label.setText( + f"Date Created: {dt.strftime(created, "%a, %x, %X")}" + ) + self.date_modified_label.setText( + f"Date Modified: {dt.strftime(modified, "%a, %x, %X")}" + ) + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + elif filepath: + self.date_created_label.setText("Date Created: N/A") + self.date_modified_label.setText("Date Modified: N/A") + self.date_created_label.setHidden(False) + self.date_modified_label.setHidden(False) + else: + self.date_created_label.setHidden(True) + self.date_modified_label.setHidden(True) + def update_widgets(self) -> bool: """Render the panel widgets with the newest data from the Library.""" logger.info("update_widgets", selected=self.driver.selected) @@ -442,11 +509,12 @@ class PreviewPanel(QWidget): if not self.driver.selected: if self.selected or not self.initialized: - self.file_label.setText("No Items Selected") + self.file_label.setText("No Items Selected") self.file_label.set_file_path("") self.file_label.setCursor(Qt.CursorShape.ArrowCursor) self.dimensions_label.setText("") + self.update_date_label() self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) self.preview_img.setCursor(Qt.CursorShape.ArrowCursor) @@ -466,6 +534,7 @@ class PreviewPanel(QWidget): self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.preview_gif.hide() self.selected = list(self.driver.selected) self.add_field_button.setHidden(True) @@ -497,6 +566,7 @@ class PreviewPanel(QWidget): self.preview_img.show() self.preview_vid.stop() self.preview_vid.hide() + self.preview_gif.hide() # If a new selection is made, update the thumbnail and filepath. if not self.selected or self.selected != self.driver.selected: @@ -510,7 +580,15 @@ class PreviewPanel(QWidget): ratio, update_on_ratio_change=True, ) - self.file_label.setText("\u200b".join(str(filepath))) + file_str: str = "" + separator: str = f"{os.path.sep}" # Gray + for i, part in enumerate(filepath.parts): + part_ = part.strip(os.path.sep) + if i != len(filepath.parts) - 1: + file_str += f"{"\u200b".join(part_)}{separator}" + else: + file_str += f"
{"\u200b".join(part_)}" + self.file_label.setText(file_str) self.file_label.setCursor(Qt.CursorShape.PointingHandCursor) self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) @@ -520,12 +598,42 @@ class PreviewPanel(QWidget): self.open_file_action.triggered.connect(self.opener.open_file) self.open_explorer_action.triggered.connect(self.opener.open_explorer) - # TODO: Do this somewhere else, this is just here temporarily. + # TODO: Do this all somewhere else, this is just here temporarily. + ext: str = filepath.suffix.lower() try: - image = None - if filepath.suffix.lower() in IMAGE_TYPES: + if filepath.suffix.lower() in [".gif"]: + with open(filepath, mode="rb") as file: + if self.preview_gif.movie(): + self.preview_gif.movie().stop() + self.gif_buffer.close() + + ba = file.read() + self.gif_buffer.setData(ba) + movie = QMovie(self.gif_buffer, QByteArray()) + self.preview_gif.setMovie(movie) + movie.start() + image = Image.open(str(filepath)) - elif filepath.suffix.lower() in RAW_IMAGE_TYPES: + self.resizeEvent( + QResizeEvent( + QSize(image.width, image.height), + QSize(image.width, image.height), + ) + ) + self.preview_img.hide() + self.preview_vid.hide() + self.preview_gif.show() + + image = None + if ( + MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_TYPES) + and MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES) + and MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_VECTOR_TYPES + ) + ): + image = Image.open(str(filepath)) + elif MediaCategories.is_ext_in_category(ext, MediaCategories.IMAGE_RAW_TYPES): try: with rawpy.imread(str(filepath)) as raw: rgb = raw.postprocess() @@ -535,11 +643,14 @@ class PreviewPanel(QWidget): rawpy._rawpy.LibRawFileUnsupportedError, ): pass - elif filepath.suffix.lower() in VIDEO_TYPES: - video = cv2.VideoCapture(str(filepath)) - 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, 0) + 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() frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) image = Image.fromarray(frame) @@ -555,35 +666,60 @@ class PreviewPanel(QWidget): self.preview_vid.show() # Stats for specific file types are displayed here. - if image and filepath.suffix.lower() in ( - IMAGE_TYPES + VIDEO_TYPES + RAW_IMAGE_TYPES + if image and ( + MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_TYPES, mime_fallback=True + ) + or MediaCategories.is_ext_in_category( + ext, MediaCategories.VIDEO_TYPES, mime_fallback=True + ) + or MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True + ) ): self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]}" - f" • {format_size(filepath.stat().st_size)}\n{image.width} " - f"x {image.height} px" + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" + f"{image.width} x {image.height} px" ) + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.FONT_TYPES, mime_fallback=True + ): + try: + font = ImageFont.truetype(filepath) + self.dimensions_label.setText( + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}\n" + f"{font.getname()[0]} ({font.getname()[1]}) " + ) + except OSError: + self.dimensions_label.setText( + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" + ) + logger.info( + f"[PreviewPanel][ERROR] Couldn't read font file: {filepath}" + ) else: + self.dimensions_label.setText(f"{ext.upper()[1:]}") self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]}" - f" • {format_size(filepath.stat().st_size)}" + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" ) + self.update_date_label(filepath) if not filepath.is_file(): raise FileNotFoundError except (FileNotFoundError, cv2.error) as e: - self.dimensions_label.setText(f"{filepath.suffix.upper()}") - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) - + self.dimensions_label.setText(f"{ext.upper()[1:]}") + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + self.update_date_label() except ( UnidentifiedImageError, DecompressionBombError, ) as e: self.dimensions_label.setText( - f"{filepath.suffix.upper()[1:]} • {format_size(filepath.stat().st_size)}" + f"{ext.upper()[1:]} • {format_size(filepath.stat().st_size)}" ) - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + self.update_date_label(filepath) if self.preview_img.is_connected: self.preview_img.clicked.disconnect() @@ -610,10 +746,12 @@ class PreviewPanel(QWidget): # Multiple Selected Items elif len(self.driver.selected) > 1: self.preview_img.show() + self.preview_gif.hide() self.preview_vid.stop() self.preview_vid.hide() + self.update_date_label() if self.selected != self.driver.selected: - self.file_label.setText(f"{len(self.driver.selected)} Items Selected") + self.file_label.setText(f"{len(self.driver.selected)} Items Selected") self.file_label.setCursor(Qt.CursorShape.ArrowCursor) self.file_label.set_file_path("") self.dimensions_label.setText("") diff --git a/tagstudio/src/qt/widgets/thumb_button.py b/tagstudio/src/qt/widgets/thumb_button.py index e56408b7..cf8dae37 100644 --- a/tagstudio/src/qt/widgets/thumb_button.py +++ b/tagstudio/src/qt/widgets/thumb_button.py @@ -5,19 +5,51 @@ from PySide6 import QtCore from PySide6.QtCore import QEvent -from PySide6.QtGui import QColor, QEnterEvent, QPainter, QPainterPath, QPaintEvent, QPen +from PySide6.QtGui import ( + QColor, + QEnterEvent, + QPainter, + QPainterPath, + QPaintEvent, + QPalette, + QPen, +) from PySide6.QtWidgets import QWidget from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper class ThumbButton(QPushButtonWrapper): - def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: + def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: # noqa: N802 super().__init__(parent) self.thumb_size: tuple[int, int] = thumb_size self.hovered = False self.selected = False - # self.clicked.connect(lambda checked: self.set_selected(True)) + self.select_color: QColor = QPalette.color( + self.palette(), + QPalette.ColorGroup.Active, + QPalette.ColorRole.Accent, + ) + + self.select_color_faded: QColor = QColor(self.select_color) + self.select_color_faded.setHsl( + self.select_color_faded.hslHue(), + self.select_color_faded.hslSaturation(), + max(self.select_color_faded.lightness(), 127), + 127, + ) + + self.hover_color: QColor = QPalette.color( + self.palette(), + QPalette.ColorGroup.Active, + QPalette.ColorRole.Accent, + ) + self.hover_color.setHsl( + self.hover_color.hslHue(), + self.hover_color.hslSaturation(), + min(self.hover_color.lightness() + 80, 255), + self.hover_color.alpha(), + ) def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 super().paintEvent(event) @@ -25,7 +57,6 @@ class ThumbButton(QPushButtonWrapper): painter = QPainter() painter.begin(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) - # painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) path = QPainterPath() width = 3 radius = 6 @@ -40,32 +71,24 @@ class ThumbButton(QPushButtonWrapper): radius, ) - # color = QColor('#bb4ff0') if self.selected else QColor('#55bbf6') - # pen = QPen(color, width) - # painter.setPen(pen) - # # brush.setColor(fill) - # painter.drawPath(path) - if self.selected: painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_HardLight) - color = QColor("#bb4ff0") - color.setAlphaF(0.5) - pen = QPen(color, width) + pen = QPen(self.select_color_faded, width) painter.setPen(pen) - painter.fillPath(path, color) + painter.fillPath(path, self.select_color_faded) painter.drawPath(path) painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - color = QColor("#bb4ff0") if not self.hovered else QColor("#55bbf6") + color: QColor = self.select_color if not self.hovered else self.hover_color pen = QPen(color, width) painter.setPen(pen) painter.drawPath(path) elif self.hovered: painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - color = QColor("#55bbf6") - pen = QPen(color, width) + pen = QPen(self.hover_color, width) painter.setPen(pen) painter.drawPath(path) + painter.end() def enterEvent(self, event: QEnterEvent) -> None: # noqa: N802 @@ -78,6 +101,6 @@ class ThumbButton(QPushButtonWrapper): self.repaint() return super().leaveEvent(event) - def set_selected(self, value: bool) -> None: + def set_selected(self, value: bool) -> None: # noqa: N802 self.selected = value self.repaint() diff --git a/tagstudio/src/qt/widgets/thumb_renderer.py b/tagstudio/src/qt/widgets/thumb_renderer.py index ff9a99b0..83eb1c57 100644 --- a/tagstudio/src/qt/widgets/thumb_renderer.py +++ b/tagstudio/src/qt/widgets/thumb_renderer.py @@ -4,14 +4,21 @@ import math +import struct +from copy import deepcopy +from io import BytesIO from pathlib import Path import cv2 +import numpy as np import rawpy import structlog +from mutagen import MutagenError, flac, id3, mp4 from PIL import ( Image, + ImageChops, ImageDraw, + ImageEnhance, ImageFile, ImageFont, ImageOps, @@ -20,68 +27,842 @@ from PIL import ( ) from PIL.Image import DecompressionBombError from pillow_heif import register_avif_opener, register_heif_opener -from PySide6.QtCore import QObject, QSize, Signal -from PySide6.QtGui import QPixmap -from src.core.constants import ( - IMAGE_TYPES, - PLAINTEXT_TYPES, - RAW_IMAGE_TYPES, - VIDEO_TYPES, -) +from pydub import exceptions +from PySide6.QtCore import QObject, QSize, Qt, Signal +from PySide6.QtGui import QGuiApplication, QPixmap +from src.core.constants import FONT_SAMPLE_SIZES, FONT_SAMPLE_TEXT +from src.core.media_types import MediaCategories, MediaType +from src.core.palette import ColorType, UiColor, get_ui_color from src.core.utils.encoding import detect_char_encoding -from src.qt.helpers.gradient import four_corner_gradient_background +from src.qt.helpers.blender_thumbnailer import blend_thumb +from src.qt.helpers.color_overlay import theme_fg_overlay +from src.qt.helpers.file_tester import is_readable_video +from src.qt.helpers.gradient import four_corner_gradient +from src.qt.helpers.text_wrapper import wrap_full_text +from src.qt.helpers.vendored.pydub.audio_segment import ( # type: ignore + _AudioSegment as AudioSegment, +) +from src.qt.resource_manager import ResourceManager +from vtf2img import Parser ImageFile.LOAD_TRUNCATED_IMAGES = True - logger = structlog.get_logger(__name__) - register_heif_opener() register_avif_opener() class ThumbRenderer(QObject): - # finished = Signal() + """A class for rendering image and file thumbnails.""" + + rm: ResourceManager = ResourceManager() updated = Signal(float, QPixmap, QSize, str) updated_ratio = Signal(float) - # updatedImage = Signal(QPixmap) - # updatedSize = Signal(QSize) - thumb_mask_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_mask_512.png" - ) - thumb_mask_512.load() + def __init__(self) -> None: + """Initialize the class.""" + super().__init__() - thumb_mask_hl_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_mask_hl_512.png" - ) - thumb_mask_hl_512.load() + # Cached thumbnail elements. + # Key: Size + Pixel Ratio Tuple + Radius Scale + # (Ex. (512, 512, 1.25, 4)) + self.thumb_masks: dict = {} + self.raised_edges: dict = {} - thumb_loading_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_loading_512.png" - ) - thumb_loading_512.load() + # Key: ("name", UiColor, 512, 512, 1.25) + self.icons: dict = {} - thumb_broken_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_broken_512.png" - ) - thumb_broken_512.load() + def _get_resource_id(self, url: Path) -> str: + """Return the name of the icon resource to use for a file type. - thumb_file_default_512: Image.Image = Image.open( - Path(__file__).parents[3] / "resources/qt/images/thumb_file_default_512.png" - ) - thumb_file_default_512.load() + Special terms will return special resources. - # thumb_debug: Image.Image = Image.open(Path( - # f'{Path(__file__).parents[2]}/resources/qt/images/temp.jpg')) - # thumb_debug.load() + Args: + url (Path): The file url to assess. "$LOADING" will return the loading graphic. + """ + ext = url.suffix.lower() + types: set[MediaType] = MediaCategories.get_types(ext, mime_fallback=True) - # TODO: Make dynamic font sized given different pixel ratios - font_pixel_ratio: float = 1 - ext_font = ImageFont.truetype( - Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * font_pixel_ratio), - ) + # Loop though the specific (non-IANA) categories and return the string + # name of the first matching category found. + for cat in MediaCategories.ALL_CATEGORIES: + if not cat.is_iana and cat.media_type in types: + return cat.media_type.value + + # If the type is broader (IANA registered) then search those types. + for cat in MediaCategories.ALL_CATEGORIES: + if cat.is_iana and cat.media_type in types: + return cat.media_type.value + + return "file_generic" + + def _get_mask( + self, size: tuple[int, int], pixel_ratio: float, scale_radius: bool = False + ) -> Image.Image: + """Return a thumbnail mask given a size, pixel ratio, and radius scaling option. + + If one is not already cached, a new one will be rendered. + + Args: + size (tuple[int, int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + scale_radius (bool): Option to scale the radius up (Used for Preview Panel). + """ + thumb_scale: int = 512 + radius_scale: float = 1 + if scale_radius: + radius_scale = max(size[0], size[1]) / thumb_scale + + item: Image.Image = self.thumb_masks.get((*size, pixel_ratio, radius_scale)) + if not item: + item = self._render_mask(size, pixel_ratio, radius_scale) + self.thumb_masks[(*size, pixel_ratio, radius_scale)] = item + return item + + def _get_edge( + self, size: tuple[int, int], pixel_ratio: float + ) -> tuple[Image.Image, Image.Image]: + """Return a thumbnail edge given a size, pixel ratio, and radius scaling option. + + If one is not already cached, a new one will be rendered. + + Args: + size (tuple[int, int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + """ + item: tuple[Image.Image, Image.Image] = self.raised_edges.get((*size, pixel_ratio)) + if not item: + item = self._render_edge(size, pixel_ratio) + self.raised_edges[(*size, pixel_ratio)] = item + return item + + def _get_icon( + self, name: str, color: UiColor, size: tuple[int, int], pixel_ratio: float = 1.0 + ) -> Image.Image: + """Return an icon given a size, pixel ratio, and radius scaling option. + + Args: + name (str): The name of the icon resource. "thumb_loading" will not draw a border. + color (str): The color to use for the icon. + size (tuple[int,int]): The size of the icon. + pixel_ratio (float): The screen pixel ratio. + """ + draw_border: bool = True + if name == "thumb_loading": + draw_border = False + + item: Image.Image = self.icons.get((name, color, *size, pixel_ratio)) + if not item: + item_flat: Image.Image = self._render_icon(name, color, size, pixel_ratio, draw_border) + edge: tuple[Image.Image, Image.Image] = self._get_edge(size, pixel_ratio) + item = self._apply_edge(item_flat, edge, faded=True) + self.icons[(name, color, *size, pixel_ratio)] = item + return item + + def _render_mask( + self, size: tuple[int, int], pixel_ratio: float, radius_scale: float = 1 + ) -> Image.Image: + """Render a thumbnail mask graphic. + + Args: + size (tuple[int,int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + radius_scale (float): The scale factor of the border radius (Used by Preview Panel). + """ + smooth_factor: int = 2 + radius_factor: int = 8 + + im: Image.Image = Image.new( + mode="L", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="black", + ) + draw = ImageDraw.Draw(im) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im.size]), + radius=math.ceil(radius_factor * smooth_factor * pixel_ratio * radius_scale), + fill="white", + ) + im = im.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + return im + + def _render_edge( + self, size: tuple[int, int], pixel_ratio: float + ) -> tuple[Image.Image, Image.Image]: + """Render a thumbnail edge graphic. + + Args: + size (tuple[int,int]): The size of the graphic. + pixel_ratio (float): The screen pixel ratio. + """ + smooth_factor: int = 2 + radius_factor: int = 8 + width: int = math.floor(pixel_ratio * 2) + + # Highlight + im_hl: Image.Image = Image.new( + mode="RGBA", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#00000000", + ) + draw = ImageDraw.Draw(im_hl) + draw.rounded_rectangle( + (width, width) + tuple([d - (width + 1) for d in im_hl.size]), + radius=math.ceil((radius_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 3)), + fill=None, + outline="white", + width=width, + ) + im_hl = im_hl.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + + # Shadow + im_sh: Image.Image = Image.new( + mode="RGBA", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#00000000", + ) + draw = ImageDraw.Draw(im_sh) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im_sh.size]), + radius=math.ceil(radius_factor * smooth_factor * pixel_ratio), + fill=None, + outline="black", + width=width, + ) + im_sh = im_sh.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + + return (im_hl, im_sh) + + def _render_icon( + self, + name: str, + color: UiColor, + size: tuple[int, int], + pixel_ratio: float, + draw_border: bool = True, + ) -> Image.Image: + """Render a thumbnail icon. + + Args: + name (str): The name of the icon resource. + color (UiColor): The color to use for the icon. + size (tuple[int,int]): The size of the icon. + pixel_ratio (float): The screen pixel ratio. + draw_border (bool): Option to draw a border. + """ + border_factor: int = 5 + smooth_factor: int = math.ceil(2 * pixel_ratio) + radius_factor: int = 8 + icon_ratio: float = 1.75 + + # Create larger blank image based on smooth_factor + im: Image.Image = Image.new( + "RGBA", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#00000000", + ) + + # Create solid background color + bg: Image.Image = Image.new( + "RGB", + size=tuple([d * smooth_factor for d in size]), # type: ignore + color="#000000", + ) + + # Paste background color with rounded rectangle mask onto blank image + im.paste( + bg, + (0, 0), + mask=self._get_mask( + tuple([d * smooth_factor for d in size]), # type: ignore + (pixel_ratio * smooth_factor), + ), + ) + + # Draw rounded rectangle border + if draw_border: + draw = ImageDraw.Draw(im) + draw.rounded_rectangle( + (0, 0) + tuple([d - 1 for d in im.size]), + radius=math.ceil( + (radius_factor * smooth_factor * pixel_ratio) + (pixel_ratio * 1.5) + ), + fill="black", + outline="#FF0000", + width=math.floor( + (border_factor * smooth_factor * pixel_ratio) - (pixel_ratio * 1.5) + ), + ) + + # Resize image to final size + im = im.resize( + size, + resample=Image.Resampling.BILINEAR, + ) + fg: Image.Image = Image.new( + "RGB", + size=size, + color="#00FF00", + ) + + # Get icon by name + icon: Image.Image = self.rm.get(name) + if not icon: + icon = self.rm.get("file_generic") + if not icon: + icon = Image.new(mode="RGBA", size=(32, 32), color="magenta") + + # Resize icon to fit icon_ratio + icon = icon.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))) + + # Paste icon centered + im.paste( + im=fg.resize((math.ceil(size[0] // icon_ratio), math.ceil(size[1] // icon_ratio))), + box=( + math.ceil((size[0] - (size[0] // icon_ratio)) // 2), + math.ceil((size[1] - (size[1] // icon_ratio)) // 2), + ), + mask=icon.getchannel(3), + ) + + # Apply color overlay + im = self._apply_overlay_color( + im, + color, + ) + + return im + + def _apply_overlay_color(self, image: Image.Image, color: UiColor) -> Image.Image: + """Apply a color overlay effect to an image based on its color channel data. + + Red channel for foreground, green channel for outline, none for background. + + Args: + image (Image.Image): The image to apply an overlay to. + color (UiColor): The name of the ColorType color to use. + """ + bg_color: str = ( + get_ui_color(ColorType.DARK_ACCENT, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.PRIMARY, color) + ) + fg_color: str = ( + get_ui_color(ColorType.PRIMARY, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + ol_color: str = ( + get_ui_color(ColorType.BORDER, color) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else get_ui_color(ColorType.LIGHT_ACCENT, color) + ) + + bg: Image.Image = Image.new(image.mode, image.size, color=bg_color) + fg: Image.Image = Image.new(image.mode, image.size, color=fg_color) + ol: Image.Image = Image.new(image.mode, image.size, color=ol_color) + + bg.paste(fg, (0, 0), mask=image.getchannel(0)) + bg.paste(ol, (0, 0), mask=image.getchannel(1)) + + if image.mode == "RGBA": + alpha_bg: Image.Image = bg.copy() + alpha_bg.convert("RGBA") + alpha_bg.putalpha(0) + alpha_bg.paste(bg, (0, 0), mask=image.getchannel(3)) + bg = alpha_bg + + return bg + + def _apply_edge( + self, + image: Image.Image, + edge: tuple[Image.Image, Image.Image], + faded: bool = False, + ): + """Apply a given edge effect to an image. + + Args: + image (Image.Image): The image to apply the edge to. + edge (tuple[Image.Image, Image.Image]): The edge images to apply. + Item 0 is the inner highlight, and item 1 is the outer shadow. + faded (bool): Whether or not to apply a faded version of the edge. + Used for light themes. + """ + opacity: float = 1.0 if not faded else 0.8 + shade_reduction: float = ( + 0 if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark else 0.3 + ) + im: Image.Image = image + im_hl, im_sh = deepcopy(edge) + + # Configure and apply a soft light overlay. + # This makes up the bulk of the effect. + im_hl.putalpha(ImageEnhance.Brightness(im_hl.getchannel(3)).enhance(opacity)) + im.paste(ImageChops.soft_light(im, im_hl), mask=im_hl.getchannel(3)) + + # Configure and apply a normal shading overlay. + # This helps with contrast. + im_sh.putalpha( + ImageEnhance.Brightness(im_sh.getchannel(3)).enhance(max(0, opacity - shade_reduction)) + ) + im.paste(im_sh, mask=im_sh.getchannel(3)) + + return im + + def _audio_album_thumb(self, 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 + 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") + 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 + 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 = mp4_tags.get("covr") + if mp4_covers: + artwork = Image.open(BytesIO(mp4_covers[0])) + if artwork: + image = artwork + except ( + mp4.MP4MetadataError, + mp4.MP4StreamInfoError, + id3.ID3NoHeaderError, + MutagenError, + ) as e: + logger.error("Couldn't read album artwork", path=filepath, error=e) + return image + + def _audio_waveform_thumb( + self, 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 + + try: + bar_count: int = min(math.floor((size // pixel_ratio) / 5), 64) + audio: AudioSegment = AudioSegment.from_file(filepath, ext[1:]) + data = np.fromstring(audio._data, np.int16) # type: ignore + 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 = [] + 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 + if abs(d) > maximum_item: + maximum_item = 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 exceptions.CouldntDecodeError as e: + logger.error("Couldn't render waveform", path=filepath.name, error=e) + + return im + + def _blender(self, filepath: Path) -> Image.Image: + """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 + try: + blend_image = blend_thumb(str(filepath)) + + bg = Image.new("RGB", blend_image.size, color=bg_color) + bg.paste(blend_image, mask=blend_image.getchannel(3)) + im = bg + + except ( + AttributeError, + UnidentifiedImageError, + FileNotFoundError, + TypeError, + ) as e: + if str(e) == "expected string or buffer": + logger.info( + f"[ThumbRenderer][BLENDER][INFO] {filepath.name} " + f"Doesn't have an embedded thumbnail. ({type(e).__name__})" + ) + + else: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + return im + + def _source_engine(self, filepath: Path) -> Image.Image: + """This is a function to convert the VTF (Valve Texture Format) files to thumbnails. + + It works using the VTF2IMG library for PILLOW. + """ + parser = Parser(filepath) + im: Image.Image = None + try: + im = parser.get_image() + + except ( + AttributeError, + UnidentifiedImageError, + FileNotFoundError, + TypeError, + struct.error, + ) as e: + if str(e) == "expected string or buffer": + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + + else: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + return im + + def _font_short_thumb(self, filepath: Path, size: int) -> Image.Image: + """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 + 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 = data.any(0) + row: 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 = self._apply_overlay_color(bg, UiColor.PURPLE) + except OSError as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + return im + + def _font_long_thumb(self, filepath: Path, size: int) -> Image.Image: + """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 + 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 + + 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 = theme_fg_overlay(bg, use_alpha=False) + except OSError as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + return im + + def _image_raw_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a RAW image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + try: + with rawpy.imread(str(filepath)) as raw: + rgb = raw.postprocess() + im = Image.frombytes( + "RGB", + (rgb.shape[1], rgb.shape[0]), + rgb, + decoder_name="raw", + ) + except DecompressionBombError as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + except ( + rawpy._rawpy.LibRawIOError, + rawpy._rawpy.LibRawFileUnsupportedError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + return im + + def _image_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a standard image type. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = None + try: + im = Image.open(filepath) + 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 + + im = ImageOps.exif_transpose(im) + except ( + UnidentifiedImageError, + DecompressionBombError, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + return im + + def _image_vector_thumb(self, 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. + """ + # TODO: Implement. + im: Image.Image = None + return im + + def _model_stl_thumb(self, filepath: Path, size: int) -> Image.Image: + """Render a thumbnail for an STL file. + + Args: + filepath (Path): The path of the file. + size (tuple[int,int]): The size of the icon. + """ + # TODO: Implement. + # The following commented code describes a method for rendering via + # matplotlib. + # This implementation did not play nice with multithreading. + im: Image.Image = None + # # Create a new plot + # matplotlib.use('agg') + # figure = plt.figure() + # axes = figure.add_subplot(projection='3d') + + # # Load the STL files and add the vectors to the plot + # your_mesh = mesh.Mesh.from_file(_filepath) + + # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) + # poly_collection.set_color((0,0,1)) # play with color + # scale = your_mesh.points.flatten() + # axes.auto_scale_xyz(scale, scale, scale) + # axes.add_collection3d(poly_collection) + # # plt.show() + # img_buf = io.BytesIO() + # plt.savefig(img_buf, format='png') + # im = Image.open(img_buf) + + return im + + def _text_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a plaintext file. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = 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, + ) as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + return im + + def _video_thumb(self, filepath: Path) -> Image.Image: + """Render a thumbnail for a video file. + + Args: + filepath (Path): The path of the file. + """ + im: Image.Image = 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 + 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=e) + return im def render( self, @@ -90,29 +871,42 @@ class ThumbRenderer(QObject): base_size: tuple[int, int], pixel_ratio: float, is_loading: bool = False, - gradient: bool = False, + is_grid_thumb: bool = False, update_on_ratio_change: bool = False, ): - """Internal renderer. Render an entry/element thumbnail for the GUI.""" - logger.debug("rendering thumbnail", path=filepath) + """Render a thumbnail or preview image. + Args: + timestamp (float): The timestamp for which this this job was dispatched. + filepath (str | Path): The path of the file to render a thumbnail for. + base_size (tuple[int,int]): The unmodified base size of the thumbnail. + pixel_ratio (float): The screen pixel ratio. + is_loading (bool): Is this a loading graphic? + is_grid_thumb (bool): Is this a thumbnail for the thumbnail grid? + Or else the Preview Pane? + update_on_ratio_change (bool): Should an updated ratio signal be sent? + + """ + adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) image: Image.Image = None pixmap: QPixmap = None final: Image.Image = None _filepath: Path = Path(filepath) resampling_method = Image.Resampling.BILINEAR - if ThumbRenderer.font_pixel_ratio != pixel_ratio: - ThumbRenderer.font_pixel_ratio = pixel_ratio - ThumbRenderer.ext_font = ImageFont.truetype( - Path(__file__).parents[3] / "resources/qt/fonts/Oxanium-Bold.ttf", - math.floor(12 * ThumbRenderer.font_pixel_ratio), - ) - adj_size = math.ceil(max(base_size[0], base_size[1]) * pixel_ratio) + theme_color: UiColor = ( + UiColor.THEME_LIGHT + if QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Light + else UiColor.THEME_DARK + ) + + # Initialize "Loading" thumbnail + loading_thumb: Image.Image = self._get_icon( + "thumb_loading", theme_color, (adj_size, adj_size), pixel_ratio + ) + if is_loading: - final = ThumbRenderer.thumb_loading_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) + final = loading_thumb.resize((adj_size, adj_size), resample=Image.Resampling.BILINEAR) qim = ImageQt.ImageQt(final) pixmap = QPixmap.fromImage(qim) pixmap.setDevicePixelRatio(pixel_ratio) @@ -120,92 +914,69 @@ class ThumbRenderer(QObject): self.updated_ratio.emit(1) elif _filepath: try: + ext: str = _filepath.suffix.lower() # Images ======================================================= - if _filepath.suffix.lower() in IMAGE_TYPES: - try: - image = Image.open(_filepath) - if image.mode != "RGB" and image.mode != "RGBA": - image = image.convert(mode="RGBA") - if image.mode == "RGBA": - new_bg = Image.new("RGB", image.size, color="#1e1e1e") - new_bg.paste(image, mask=image.getchannel(3)) - image = new_bg - - image = ImageOps.exif_transpose(image) - except DecompressionBombError as e: - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) - - elif _filepath.suffix.lower() in RAW_IMAGE_TYPES: - try: - with rawpy.imread(str(_filepath)) as raw: - rgb = raw.postprocess() - image = Image.frombytes( - "RGB", - (rgb.shape[1], rgb.shape[0]), - rgb, - decoder_name="raw", - ) - except DecompressionBombError as e: - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) - - except ( - rawpy._rawpy.LibRawIOError, - rawpy._rawpy.LibRawFileUnsupportedError, - ) as e: - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) - + if MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_TYPES, mime_fallback=True + ): + # Raw Images ----------------------------------------------- + if MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True + ): + image = self._image_raw_thumb(_filepath) + elif MediaCategories.is_ext_in_category( + ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True + ): + image = self._image_vector_thumb(_filepath, adj_size) + # Normal Images -------------------------------------------- + else: + image = self._image_thumb(_filepath) # Videos ======================================================= - elif _filepath.suffix.lower() in VIDEO_TYPES: - video = cv2.VideoCapture(str(_filepath)) - frame_count = video.get(cv2.CAP_PROP_FRAME_COUNT) - if frame_count <= 0: - raise cv2.error("File is invalid or has 0 frames") - video.set(cv2.CAP_PROP_POS_FRAMES, frame_count // 2) - success, frame = video.read() - if not success: - # Depending on the video format, compression, and frame - # count, seeking halfway does not work and the thumb - # must be pulled from the earliest available frame. - video.set(cv2.CAP_PROP_POS_FRAMES, 0) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - + if MediaCategories.is_ext_in_category( + ext, MediaCategories.VIDEO_TYPES, mime_fallback=True + ): + image = self._video_thumb(_filepath) # Plain Text =================================================== - elif _filepath.suffix.lower() in PLAINTEXT_TYPES: - 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="#1e1e1e") - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, file=(255, 255, 255)) - image = bg - # 3D =========================================================== - # elif extension == 'stl': - # # Create a new plot - # matplotlib.use('agg') - # figure = plt.figure() - # axes = figure.add_subplot(projection='3d') + if MediaCategories.is_ext_in_category( + ext, MediaCategories.PLAINTEXT_TYPES, mime_fallback=True + ): + image = self._text_thumb(_filepath) + # Fonts ======================================================== + if MediaCategories.is_ext_in_category( + ext, MediaCategories.FONT_TYPES, mime_fallback=True + ): + if is_grid_thumb: + # Short (Aa) Preview + image = self._font_short_thumb(_filepath, adj_size) + else: + # Large (Full Alphabet) Preview + image = self._font_long_thumb(_filepath, adj_size) + # Audio ======================================================== + if MediaCategories.is_ext_in_category( + ext, MediaCategories.AUDIO_TYPES, mime_fallback=True + ): + image = self._audio_album_thumb(_filepath, ext) + if image is None: + image = self._audio_waveform_thumb(_filepath, ext, adj_size, pixel_ratio) + if image is not None: + image = self._apply_overlay_color(image, UiColor.GREEN) - # # Load the STL files and add the vectors to the plot - # your_mesh = mesh.Mesh.from_file(_filepath) + # Blender =========================================================== + if MediaCategories.is_ext_in_category( + ext, MediaCategories.BLENDER_TYPES, mime_fallback=True + ): + image = self._blender(_filepath) + + # VTF ========================================================== + if MediaCategories.is_ext_in_category( + ext, MediaCategories.SOURCE_ENGINE_TYPES, mime_fallback=True + ): + image = self._source_engine(_filepath) - # poly_collection = mplot3d.art3d.Poly3DCollection(your_mesh.vectors) - # poly_collection.set_color((0,0,1)) # play with color - # scale = your_mesh.points.flatten() - # axes.auto_scale_xyz(scale, scale, scale) - # axes.add_collection3d(poly_collection) - # # plt.show() - # img_buf = io.BytesIO() - # plt.savefig(img_buf, format='png') - # image = Image.open(img_buf) # No Rendered Thumbnail ======================================== - else: - image = ThumbRenderer.thumb_file_default_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ) - - if not image: + if not _filepath.exists(): + raise FileNotFoundError + elif not image: raise UnidentifiedImageError orig_x, orig_y = image.size @@ -227,47 +998,46 @@ class ThumbRenderer(QObject): else Image.Resampling.BILINEAR ) image = image.resize((new_x, new_y), resample=resampling_method) - if gradient: - mask: Image.Image = ThumbRenderer.thumb_mask_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR - ).getchannel(3) - hl: Image.Image = ThumbRenderer.thumb_mask_hl_512.resize( - (adj_size, adj_size), resample=Image.Resampling.BILINEAR + mask: Image.Image = None + if is_grid_thumb: + mask = self._get_mask((adj_size, adj_size), pixel_ratio) + edge: tuple[Image.Image, Image.Image] = self._get_edge( + (adj_size, adj_size), pixel_ratio + ) + final = self._apply_edge( + four_corner_gradient(image, (adj_size, adj_size), mask), + edge, ) - final = four_corner_gradient_background(image, adj_size, mask, hl) else: - scalar = 4 - rec: Image.Image = Image.new( - "RGB", - tuple([d * scalar for d in image.size]), # type: ignore - "black", - ) - draw = ImageDraw.Draw(rec) - draw.rounded_rectangle( - (0, 0) + rec.size, - (base_size[0] // 32) * scalar * pixel_ratio, - fill="red", - ) - rec = rec.resize( - tuple([d // scalar for d in rec.size]), - resample=Image.Resampling.BILINEAR, - ) + mask = self._get_mask(image.size, pixel_ratio, scale_radius=True) final = Image.new("RGBA", image.size, (0, 0, 0, 0)) - final.paste(image, mask=rec.getchannel(0)) + final.paste(image, mask=mask.getchannel(0)) + + except FileNotFoundError as e: + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) + if update_on_ratio_change: + self.updated_ratio.emit(1) + final = self._get_icon( + name="broken_link_icon", + color=UiColor.RED, + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, + ) except ( UnidentifiedImageError, - FileNotFoundError, - cv2.error, DecompressionBombError, - UnicodeDecodeError, + ValueError, + ChildProcessError, ) as e: - if e is not UnicodeDecodeError: - logger.error("Couldn't Render thumbnail", filepath=filepath, error=e) + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) if update_on_ratio_change: self.updated_ratio.emit(1) - final = ThumbRenderer.thumb_broken_512.resize( - (adj_size, adj_size), resample=resampling_method + final = self._get_icon( + name=self._get_resource_id(_filepath), + color=theme_color, + size=(adj_size, adj_size), + pixel_ratio=pixel_ratio, ) qim = ImageQt.ImageQt(final) if image: diff --git a/tagstudio/src/qt/widgets/video_player.py b/tagstudio/src/qt/widgets/video_player.py index e1a4c141..2b4434b1 100644 --- a/tagstudio/src/qt/widgets/video_player.py +++ b/tagstudio/src/qt/widgets/video_player.py @@ -30,6 +30,7 @@ from PySide6.QtSvgWidgets import QSvgWidget from PySide6.QtWidgets import QGraphicsScene, QGraphicsView from src.core.enums import SettingItems from src.qt.helpers.file_opener import FileOpenerHelper +from src.qt.platform_strings import PlatformStrings if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver @@ -75,6 +76,8 @@ class VideoPlayer(QGraphicsView): self.scene().addItem(self.video_preview) self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) + self.setStyleSheet("border-style:solid;border-width:0px;") + # Set up the video tint. self.video_tint = self.scene().addRect( 0, @@ -116,14 +119,16 @@ class VideoPlayer(QGraphicsView): autoplay_action.setCheckable(True) self.addAction(autoplay_action) autoplay_action.setChecked( - bool(self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool)) + self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool) # type: ignore ) autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) self.autoplay = autoplay_action open_file_action = QAction("Open file", self) open_file_action.triggered.connect(self.opener.open_file) - open_explorer_action = QAction("Open file in explorer", self) + + open_explorer_action = QAction(PlatformStrings.open_file_str, self) + open_explorer_action.triggered.connect(self.opener.open_explorer) self.addAction(open_file_action) self.addAction(open_explorer_action) diff --git a/tagstudio/tests/qt/test_preview_panel.py b/tagstudio/tests/qt/test_preview_panel.py index 3b612e32..f8550b86 100644 --- a/tagstudio/tests/qt/test_preview_panel.py +++ b/tagstudio/tests/qt/test_preview_panel.py @@ -16,7 +16,7 @@ def test_update_widgets_not_selected(qt_driver, library): panel.update_widgets() assert panel.preview_img.isVisible() - assert panel.file_label.text() == "No Items Selected" + assert panel.file_label.text() == "No Items Selected" @pytest.mark.parametrize("library", [TemporaryDirectory()], indirect=True)