mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
feat(character-preview): attempt to display character image
This commit is contained in:
@@ -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/tty | awk "{print \$2 \"x\" \$1}")
|
||||
fi
|
||||
if ! [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$KITTY_WINDOW_ID" ] && [ "$((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES))" -eq "$(stty size </dev/tty | awk "{print \$1}")" ]; then
|
||||
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$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
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user