feat(character-preview): attempt to display character image

This commit is contained in:
Benexl
2025-07-29 15:32:21 +03:00
parent ac9b000ce8
commit cd7b70dd6b
4 changed files with 200 additions and 17 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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),

View File

@@ -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("&quot;", '"')
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&#039;", "'")
.replace("&nbsp;", " ")
)
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