From cd7b70dd6b4a2b5a246970735cea5ba02bbf1ab2 Mon Sep 17 00:00:00 2001 From: Benexl Date: Tue, 29 Jul 2025 15:32:21 +0300 Subject: [PATCH] feat(character-preview): attempt to display character image --- .../scripts/fzf/character-preview.template.sh | 55 ++++++++++ fastanime/cli/utils/image.py | 101 +++++++++++++++++- fastanime/cli/utils/preview.py | 1 + fastanime/cli/utils/preview_workers.py | 60 ++++++++--- 4 files changed, 200 insertions(+), 17 deletions(-) diff --git a/fastanime/assets/scripts/fzf/character-preview.template.sh b/fastanime/assets/scripts/fzf/character-preview.template.sh index 56456fb..566936a 100644 --- a/fastanime/assets/scripts/fzf/character-preview.template.sh +++ b/fastanime/assets/scripts/fzf/character-preview.template.sh @@ -31,7 +31,50 @@ generate_sha256() { fi } +fzf_preview() { + file=$1 + dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES} + if [ "$dim" = x ]; then + dim=$(stty size /dev/null 2>&1; then + kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" + elif command -v icat >/dev/null 2>&1; then + icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" + else + kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" + fi + + elif [ -n "$GHOSTTY_BIN_DIR" ]; then + if command -v kitten >/dev/null 2>&1; then + kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" + elif command -v icat >/dev/null 2>&1; then + icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")" + else + chafa -s "$dim" "$file" + fi + elif command -v chafa >/dev/null 2>&1; then + case "$PLATFORM" in + android) chafa -s "$dim" "$file" ;; + windows) chafa -f sixel -s "$dim" "$file" ;; + *) chafa -s "$dim" "$file" ;; + esac + echo + + elif command -v imgcat >/dev/null; then + imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file" + + else + echo please install a terminal image viewer + echo either icat for kitty terminal and wezterm or imgcat or chafa + fi +} print_kv() { local key="$1" local value="$2" @@ -65,6 +108,16 @@ draw_rule(){ title={} hash=$(generate_sha256 "$title") + +# FIXME: Disabled since they cover the text perhaps its aspect ratio related or image format not sure +# if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "image" ]; then +# image_file="{IMAGE_CACHE_DIR}{PATH_SEP}$hash.png" +# if [ -f "$image_file" ]; then +# fzf_preview "$image_file" +# echo # Add a newline for spacing +# fi +# fi + if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then info_file="{INFO_CACHE_DIR}{PATH_SEP}$hash" if [ -f "$info_file" ]; then @@ -73,3 +126,5 @@ if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then echo "👤 Loading character details..." fi fi + + diff --git a/fastanime/cli/utils/image.py b/fastanime/cli/utils/image.py index f27987c..2925e8d 100644 --- a/fastanime/cli/utils/image.py +++ b/fastanime/cli/utils/image.py @@ -1,10 +1,7 @@ -# fastanime/cli/utils/image.py - -from __future__ import annotations - import logging import shutil import subprocess +from pathlib import Path from typing import Optional import click @@ -13,6 +10,102 @@ import httpx logger = logging.getLogger(__name__) +def resize_image_from_url( + client: httpx.Client, + url: str, + new_width: int, + new_height: int, + output_path: Optional[Path] = None, + maintain_aspect_ratio: bool = False, + return_bytes: bool = True, +) -> bytes | None: + """ + Fetches an image from a URL using a provided synchronous httpx.Client, + resizes it with Pillow. Can either save the resized image to a file + or return its bytes. + + Args: + client (httpx.Client): An initialized synchronous httpx.Client instance. + url (str): The URL of the image. + new_width (int): The desired new width of the image. + new_height (int): The desired new height of the image. + output_path (str, optional): The path to save the resized image. + Required if return_bytes is False. + maintain_aspect_ratio (bool, optional): If True, resizes while maintaining + the aspect ratio using thumbnail(). + Defaults to False. + return_bytes (bool, optional): If True, returns the resized image as bytes. + If False, saves to output_path. Defaults to False. + + Returns: + bytes | None: The bytes of the resized image if return_bytes is True, + otherwise None. + """ + from io import BytesIO + + from PIL import Image + + if not return_bytes and output_path is None: + raise ValueError("output_path must be provided if return_bytes is False.") + + try: + # Use the provided synchronous client + response = client.get(url) + response.raise_for_status() # Raise an exception for bad status codes + + image_bytes = response.content + image_stream = BytesIO(image_bytes) + img = Image.open(image_stream) + + if maintain_aspect_ratio: + img_copy = img.copy() + img_copy.thumbnail((new_width, new_height), Image.Resampling.LANCZOS) + resized_img = img_copy + else: + resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + if return_bytes: + # Determine the output format. Default to JPEG if original is unknown or problematic. + # Handle RGBA to RGB conversion for JPEG output. + output_format = ( + img.format if img.format in ["JPEG", "PNG", "WEBP"] else "JPEG" + ) + if output_format == "JPEG": + if resized_img.mode in ("RGBA", "P"): + resized_img = resized_img.convert("RGB") + + byte_arr = BytesIO() + resized_img.save(byte_arr, format=output_format) + logger.info( + f"Image from {url} resized to {resized_img.width}x{resized_img.height} and returned as bytes ({output_format} format)." + ) + return byte_arr.getvalue() + else: + # Ensure the directory exists before saving + if output_path: + output_path.parent.mkdir(parents=True, exist_ok=True) + resized_img.save(output_path) + logger.info( + f"Image from {url} resized to {resized_img.width}x{resized_img.height} and saved as '{output_path}'" + ) + return None + + except httpx.RequestError as e: + logger.error(f"An error occurred while requesting {url}: {e}") + return None + except httpx.HTTPStatusError as e: + logger.error( + f"HTTP error occurred: {e.response.status_code} - {e.response.text}" + ) + return None + except ValueError as e: + logger.error(f"Configuration error: {e}") + return None + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") + return None + + def render(url: str, capture: bool = False, size: str = "30x30") -> Optional[str]: """ Renders an image from a URL in the terminal using icat or chafa. diff --git a/fastanime/cli/utils/preview.py b/fastanime/cli/utils/preview.py index 387f5a9..4304631 100644 --- a/fastanime/cli/utils/preview.py +++ b/fastanime/cli/utils/preview.py @@ -417,6 +417,7 @@ def get_character_preview(choice_map: Dict[str, Character], config: AppConfig) - replacements = { "PREVIEW_MODE": config.general.preview, "INFO_CACHE_DIR": str(INFO_CACHE_DIR), + "IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR), "PATH_SEP": path_sep, "C_TITLE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), "C_KEY": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True), diff --git a/fastanime/cli/utils/preview_workers.py b/fastanime/cli/utils/preview_workers.py index 848d401..9f6f888 100644 --- a/fastanime/cli/utils/preview_workers.py +++ b/fastanime/cli/utils/preview_workers.py @@ -25,6 +25,7 @@ from ...libs.media_api.types import ( MediaItem, MediaReview, ) +from . import image logger = logging.getLogger(__name__) @@ -489,9 +490,22 @@ class CharacterCacheWorker(ManagedBackgroundWorker): Specialized background worker for caching character preview data. """ - def __init__(self, characters_cache_dir, max_workers: int = 10): + def __init__(self, characters_cache_dir, image_cache_dir, max_workers: int = 10): super().__init__(max_workers=max_workers, name="CharacterCacheWorker") self.characters_cache_dir = characters_cache_dir + self.image_cache_dir = image_cache_dir + + self._http_client: Optional[httpx.Client] = None + + def start(self) -> None: + """Start the worker and initialize HTTP client.""" + super().start() + self._http_client = httpx.Client( + timeout=20.0, + follow_redirects=True, + limits=httpx.Limits(max_connections=self.max_workers), + ) + logger.debug("EpisodeCacheWorker HTTP client initialized") def cache_character_previews( self, choice_map: Dict[str, Character], config: AppConfig @@ -513,6 +527,14 @@ class CharacterCacheWorker(ManagedBackgroundWorker): preview_content = self._generate_character_preview_content( character, config ) + # NOTE: Disabled due to issue of the text overlapping with the image + if ( + character.image + and (character.image.medium or character.image.large) + and False + ): + image_url = character.image.medium or character.image.large + self.submit_function(self._download_and_save_image, image_url, hash_id) self.submit_function(self._save_preview_content, preview_content, hash_id) def _generate_character_preview_content( @@ -538,17 +560,7 @@ class CharacterCacheWorker(ManagedBackgroundWorker): # Clean and format description description = character.description or "No description available" if description: - import re - - description = re.sub(r"<[^>]+>", "", description) - description = ( - description.replace(""", '"') - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("'", "'") - .replace(" ", " ") - ) + description = formatter.clean_html(description) # Inject data into the presentation template template = TEMPLATE_CHARACTER_INFO_SCRIPT @@ -567,6 +579,26 @@ class CharacterCacheWorker(ManagedBackgroundWorker): return template + def _download_and_save_image(self, url: str, hash_id: str) -> None: + """Download an image and save it to cache.""" + if not self._http_client: + raise RuntimeError("HTTP client not initialized") + + image_path = self.image_cache_dir / f"{hash_id}.png" + + try: + if img_bytes := image.resize_image_from_url( + self._http_client, url, 300, 300 + ): + with AtomicWriter(image_path, "wb", encoding=None) as f: + f.write(img_bytes) + + logger.debug(f"Successfully cached image: {hash_id}") + + except Exception as e: + logger.error(f"Failed to download image {url}: {e}") + raise + def _save_preview_content(self, content: str, hash_id: str) -> None: """Saves the final preview content to the cache.""" try: @@ -790,7 +822,9 @@ class PreviewWorkerManager: # Clean up old worker thread_manager.shutdown_worker("character_cache_worker") - self._character_worker = CharacterCacheWorker(self.info_cache_dir) + self._character_worker = CharacterCacheWorker( + self.info_cache_dir, self.images_cache_dir + ) self._character_worker.start() thread_manager.register_worker( "character_cache_worker", self._character_worker