feat: Implement poster rendering options with ASCII, Viu, and RichPixels support
This commit is contained in:
BIN
dist/renamer-0.8.1-py3-none-any.whl
vendored
Normal file
BIN
dist/renamer-0.8.1-py3-none-any.whl
vendored
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "renamer"
|
||||
version = "0.7.10"
|
||||
version = "0.8.1"
|
||||
description = "Terminal-based media file renamer and metadata viewer"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -90,14 +90,40 @@ class AppCommandProvider(Provider):
|
||||
|
||||
class RenamerApp(App):
|
||||
CSS = """
|
||||
/* Default technical mode: 2 columns */
|
||||
#left {
|
||||
width: 50%;
|
||||
padding: 1;
|
||||
}
|
||||
#right {
|
||||
#middle {
|
||||
width: 50%;
|
||||
padding: 1;
|
||||
}
|
||||
#right {
|
||||
display: none; /* Hidden in technical mode */
|
||||
}
|
||||
|
||||
/* Catalog mode: 3 columns */
|
||||
.catalog-mode #left {
|
||||
width: 33%;
|
||||
}
|
||||
.catalog-mode #middle {
|
||||
width: 34%;
|
||||
}
|
||||
.catalog-mode #right {
|
||||
display: block;
|
||||
width: 33%;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#poster_container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#poster_display {
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
@@ -128,10 +154,11 @@ class RenamerApp(App):
|
||||
self.cache_manager = CacheManager(self.cache)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal():
|
||||
with Horizontal(id="main_container"):
|
||||
with Container(id="left"):
|
||||
yield Tree("Files", id="file_tree")
|
||||
with Container(id="right"):
|
||||
# Middle container (for catalog mode info)
|
||||
with Container(id="middle"):
|
||||
with Vertical():
|
||||
yield LoadingIndicator(id="loading")
|
||||
with ScrollableContainer(id="details_container"):
|
||||
@@ -142,13 +169,29 @@ class RenamerApp(App):
|
||||
"", id="details_catalog", markup=False
|
||||
)
|
||||
yield Static("", id="proposed", markup=True)
|
||||
# Right container (for poster in catalog mode, hidden in technical mode)
|
||||
with Container(id="right"):
|
||||
with ScrollableContainer(id="poster_container"):
|
||||
yield Static("", id="poster_display", markup=False)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
loading = self.query_one("#loading", LoadingIndicator)
|
||||
loading.display = False
|
||||
# Apply initial layout based on mode setting
|
||||
self._update_layout()
|
||||
self.scan_files()
|
||||
|
||||
def _update_layout(self):
|
||||
"""Update layout based on current mode setting."""
|
||||
mode = self.settings.get("mode")
|
||||
main_container = self.query_one("#main_container")
|
||||
|
||||
if mode == "catalog":
|
||||
main_container.add_class("catalog-mode")
|
||||
else:
|
||||
main_container.remove_class("catalog-mode")
|
||||
|
||||
def scan_files(self):
|
||||
logging.info("scan_files called")
|
||||
if not self.scan_dir or not self.scan_dir.exists() or not self.scan_dir.is_dir():
|
||||
@@ -276,39 +319,48 @@ class RenamerApp(App):
|
||||
extractor = MediaExtractor(file_path)
|
||||
|
||||
mode = self.settings.get("mode")
|
||||
poster_content = ""
|
||||
|
||||
if mode == "technical":
|
||||
formatter = MediaPanelView(extractor)
|
||||
full_info = formatter.file_info_panel()
|
||||
else: # catalog
|
||||
formatter = CatalogFormatter(extractor, self.settings)
|
||||
full_info = formatter.format_catalog_info()
|
||||
full_info, poster_content = formatter.format_catalog_info()
|
||||
|
||||
# Update UI
|
||||
self.call_later(
|
||||
self._update_details,
|
||||
full_info,
|
||||
ProposedFilenameView(extractor).rename_line_formatted(file_path),
|
||||
poster_content,
|
||||
)
|
||||
except Exception as e:
|
||||
self.call_later(
|
||||
self._update_details,
|
||||
TextFormatter.red(f"Error extracting details: {str(e)}"),
|
||||
"",
|
||||
"",
|
||||
)
|
||||
|
||||
def _update_details(self, full_info: str, display_string: str):
|
||||
def _update_details(self, full_info: str, display_string: str, poster_content: str = ""):
|
||||
self._stop_loading_animation()
|
||||
details_technical = self.query_one("#details_technical", Static)
|
||||
details_catalog = self.query_one("#details_catalog", Static)
|
||||
poster_display = self.query_one("#poster_display", Static)
|
||||
|
||||
mode = self.settings.get("mode")
|
||||
if mode == "technical":
|
||||
details_technical.display = True
|
||||
details_catalog.display = False
|
||||
details_technical.update(full_info)
|
||||
poster_display.update("") # Clear poster in technical mode
|
||||
else:
|
||||
details_technical.display = False
|
||||
details_catalog.display = True
|
||||
details_catalog.update(full_info)
|
||||
# Update poster panel
|
||||
poster_display.update(poster_content)
|
||||
|
||||
proposed = self.query_one("#proposed", Static)
|
||||
proposed.update(display_string)
|
||||
@@ -444,6 +496,8 @@ By Category:"""
|
||||
current_mode = self.settings.get("mode")
|
||||
new_mode = "catalog" if current_mode == "technical" else "technical"
|
||||
self.settings.set("mode", new_mode)
|
||||
# Update layout to show/hide poster panel
|
||||
self._update_layout()
|
||||
self.notify(f"Switched to {new_mode} mode", severity="information", timeout=2)
|
||||
# Refresh current file display if any
|
||||
tree = self.query_one("#file_tree", Tree)
|
||||
|
||||
@@ -5,7 +5,7 @@ and their aliases for detection in filenames.
|
||||
"""
|
||||
|
||||
SPECIAL_EDITIONS = {
|
||||
"Theatrical Cut": ["Theatrical Cut"],
|
||||
"Theatrical Cut": ["Theatrical Cut", "Theatrical Reconstruction"],
|
||||
"Director's Cut": ["Director's Cut", "Director Cut"],
|
||||
"Extended Cut": ["Extended Cut", "Ultimate Extended Cut", "Extended Edition", "Ultimate Extended Edition"],
|
||||
"Special Edition": ["Special Edition"],
|
||||
@@ -53,7 +53,7 @@ SPECIAL_EDITIONS = {
|
||||
"Workprint": ["Workprint"],
|
||||
"Rough Cut": ["Rough Cut"],
|
||||
"Special Assembly Cut": ["Special Assembly Cut"],
|
||||
"Amazon Edition": ["Amazon Edition", "Amazon", "AMZN"],
|
||||
"Amazon Edition": ["Amazon Edition", "Amazon", "Amazon Prime Edition", "Amazon Prime"],
|
||||
"Netflix Edition": ["Netflix Edition"],
|
||||
"HBO Edition": ["HBO Edition"],
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from .text_formatter import TextFormatter
|
||||
from renamer.views.posters import AsciiPosterRenderer, ViuPosterRenderer, RichPixelsPosterRenderer
|
||||
from typing import Union
|
||||
import os
|
||||
|
||||
|
||||
@@ -9,10 +11,14 @@ class CatalogFormatter:
|
||||
self.extractor = extractor
|
||||
self.settings = settings
|
||||
|
||||
def format_catalog_info(self) -> str:
|
||||
"""Format catalog information for display"""
|
||||
def format_catalog_info(self) -> tuple[str, Union[str, object]]:
|
||||
"""Format catalog information for display.
|
||||
|
||||
Returns:
|
||||
Tuple of (info_text, poster_content)
|
||||
poster_content can be a string or Rich Renderable object
|
||||
"""
|
||||
lines = []
|
||||
poster_output = None
|
||||
|
||||
# Title
|
||||
title = self.extractor.get("title", "TMDB")
|
||||
@@ -56,23 +62,6 @@ class CatalogFormatter:
|
||||
if countries:
|
||||
lines.append(f"{TextFormatter.bold('Countries:')} {countries}")
|
||||
|
||||
# Poster - check settings for display mode
|
||||
poster_mode = self.settings.get("poster", "no") if self.settings else "no"
|
||||
poster_image_path = self.extractor.tmdb_extractor.extract_poster_image_path()
|
||||
|
||||
if poster_mode != "no" and poster_image_path:
|
||||
lines.append(f"{TextFormatter.bold('Poster:')}")
|
||||
poster_output = self._display_poster(poster_image_path, poster_mode)
|
||||
elif poster_mode == "no":
|
||||
# Don't show poster at all
|
||||
poster_output = None
|
||||
else:
|
||||
# Poster path not cached yet
|
||||
poster_path = self.extractor.get("poster_path", "TMDB")
|
||||
if poster_path:
|
||||
lines.append(f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)")
|
||||
poster_output = None
|
||||
|
||||
# Render text content with Rich markup
|
||||
text_content = "\n\n".join(lines) if lines else "No catalog information available"
|
||||
|
||||
@@ -83,103 +72,55 @@ class CatalogFormatter:
|
||||
console.print(text_content, markup=True)
|
||||
rendered_text = console.file.getvalue()
|
||||
|
||||
# Append poster output if available
|
||||
# Don't process ASCII art through console - just append it directly
|
||||
if poster_output:
|
||||
return rendered_text + "\n" + poster_output
|
||||
else:
|
||||
return rendered_text
|
||||
# Get poster separately
|
||||
poster_content = self.get_poster()
|
||||
|
||||
def _display_poster(self, image_path: str, mode: str) -> str:
|
||||
return rendered_text, poster_content
|
||||
|
||||
def get_poster(self) -> Union[str, object]:
|
||||
"""Get poster content for separate display.
|
||||
|
||||
Returns:
|
||||
Poster content (string or Rich Renderable) or empty string if no poster
|
||||
"""
|
||||
poster_mode = self.settings.get("poster", "no") if self.settings else "no"
|
||||
|
||||
if poster_mode == "no":
|
||||
return ""
|
||||
|
||||
poster_image_path = self.extractor.tmdb_extractor.extract_poster_image_path()
|
||||
|
||||
if poster_image_path:
|
||||
return self._display_poster(poster_image_path, poster_mode)
|
||||
else:
|
||||
# Poster path not cached yet
|
||||
poster_path = self.extractor.get("poster_path", "TMDB")
|
||||
if poster_path:
|
||||
return f"{TextFormatter.bold('Poster:')} {poster_path} (not cached yet)"
|
||||
return ""
|
||||
|
||||
def _display_poster(self, image_path: str, mode: str) -> Union[str, object]:
|
||||
"""Display poster image based on mode setting.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image
|
||||
mode: Display mode - "pseudo" for ASCII art, "viu" for viu rendering
|
||||
mode: Display mode - "pseudo" for ASCII art, "viu", "richpixels"
|
||||
|
||||
Returns:
|
||||
Rendered poster as string
|
||||
Rendered poster (string or Rich Renderable object)
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
return f"Image file not found: {image_path}"
|
||||
|
||||
# Select renderer based on mode
|
||||
if mode == "viu":
|
||||
return self._display_poster_viu(image_path)
|
||||
renderer = ViuPosterRenderer()
|
||||
elif mode == "pseudo":
|
||||
return self._display_poster_pseudo(image_path)
|
||||
renderer = AsciiPosterRenderer()
|
||||
elif mode == "richpixels":
|
||||
renderer = RichPixelsPosterRenderer()
|
||||
else:
|
||||
return f"Unknown poster mode: {mode}"
|
||||
|
||||
def _display_poster_viu(self, image_path: str) -> str:
|
||||
"""Display poster using viu (not working in Textual, only in terminal)"""
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
# Check if viu is available
|
||||
if not shutil.which('viu'):
|
||||
return f"viu not installed. Install with: cargo install viu\nPoster at: {image_path}"
|
||||
|
||||
try:
|
||||
# Run viu to render the image
|
||||
# -w 40: width in characters
|
||||
# -t: transparent background
|
||||
result = subprocess.run(
|
||||
['viu', '-w', '40', '-t', image_path],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
# Decode bytes output, preserving ANSI escape sequences
|
||||
return result.stdout.decode('utf-8', errors='replace')
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr_msg = e.stderr.decode('utf-8', errors='replace') if e.stderr else 'Unknown error'
|
||||
return f"Failed to render image with viu: {stderr_msg}\nPoster at: {image_path}"
|
||||
except Exception as e:
|
||||
return f"Failed to display image: {e}\nPoster at: {image_path}"
|
||||
|
||||
def _display_poster_pseudo(self, image_path: str) -> str:
|
||||
"""Display poster image using ASCII art (pseudo graphics)"""
|
||||
try:
|
||||
from PIL import Image, ImageEnhance
|
||||
|
||||
# Open image
|
||||
img = Image.open(image_path)
|
||||
|
||||
# Enhance contrast for better detail
|
||||
enhancer = ImageEnhance.Contrast(img)
|
||||
img = enhancer.enhance(1.3)
|
||||
|
||||
# Convert to grayscale and resize
|
||||
# Compact size for ASCII art (35x35 -> 35x17 after row averaging)
|
||||
img = img.convert('L').resize((35, 35), Image.Resampling.LANCZOS)
|
||||
|
||||
# Extended ASCII characters from darkest to lightest (more gradient levels)
|
||||
# Using characters with different visual density for better detail
|
||||
ascii_chars = '$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,"^`\'. '
|
||||
|
||||
# Convert to ASCII
|
||||
pixels = img.getdata()
|
||||
width, height = img.size
|
||||
|
||||
ascii_art = []
|
||||
for y in range(0, height, 2): # Skip every other row for aspect ratio correction
|
||||
row = []
|
||||
for x in range(width):
|
||||
# Average of two rows for better aspect ratio
|
||||
pixel1 = pixels[y * width + x] if y < height else 255
|
||||
pixel2 = pixels[(y + 1) * width + x] if y + 1 < height else 255
|
||||
avg = (pixel1 + pixel2) // 2
|
||||
|
||||
# Map pixel brightness to character
|
||||
# Invert: 0 (black) -> dark char, 255 (white) -> light char
|
||||
char_index = (255 - avg) * (len(ascii_chars) - 1) // 255
|
||||
char = ascii_chars[char_index]
|
||||
row.append(char)
|
||||
ascii_art.append(''.join(row))
|
||||
|
||||
return '\n'.join(ascii_art)
|
||||
|
||||
except ImportError:
|
||||
return f"PIL not available for pseudo graphics\nPoster at: {image_path}"
|
||||
except Exception as e:
|
||||
return f"Failed to display image: {e}\nPoster at: {image_path}"
|
||||
# Render the poster
|
||||
return renderer.render(image_path, width=40)
|
||||
@@ -7,13 +7,14 @@ class TrackFormatter:
|
||||
codec = track.get('codec', 'unknown')
|
||||
width = track.get('width', '?')
|
||||
height = track.get('height', '?')
|
||||
bitrate = track.get('bitrate')
|
||||
bitrate = track.get('bitrate') # in bps
|
||||
bitrate_kbps = bitrate / 1024 if bitrate else None
|
||||
fps = track.get('fps')
|
||||
profile = track.get('profile')
|
||||
|
||||
video_str = f"{codec} {width}x{height}"
|
||||
if bitrate:
|
||||
video_str += f" {bitrate}bps"
|
||||
if bitrate_kbps:
|
||||
video_str += f" {bitrate_kbps}kbps"
|
||||
if fps:
|
||||
video_str += f" {fps}fps"
|
||||
if profile:
|
||||
@@ -27,12 +28,12 @@ class TrackFormatter:
|
||||
codec = track.get('codec', 'unknown')
|
||||
channels = track.get('channels', '?')
|
||||
lang = track.get('language', 'und')
|
||||
bitrate = track.get('bitrate')
|
||||
bitrate = track.get('bitrate') # in bps
|
||||
bitrate_kbps = bitrate / 1024 if bitrate else None
|
||||
|
||||
audio_str = f"{codec} {channels}ch {lang}"
|
||||
if bitrate:
|
||||
audio_str += f" {bitrate}bps"
|
||||
|
||||
if bitrate_kbps:
|
||||
audio_str += f" {bitrate_kbps}kbps"
|
||||
return audio_str
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -311,8 +311,9 @@ Configure application settings.
|
||||
yield Static("Poster Display (Catalog Mode):", classes="label")
|
||||
with Horizontal():
|
||||
yield Button("No", id="poster_no", variant="primary" if settings.get("poster") == "no" else "default")
|
||||
yield Button("Pseudo", id="poster_pseudo", variant="primary" if settings.get("poster") == "pseudo" else "default")
|
||||
yield Button("ASCII", id="poster_pseudo", variant="primary" if settings.get("poster") == "pseudo" else "default")
|
||||
yield Button("Viu", id="poster_viu", variant="primary" if settings.get("poster") == "viu" else "default")
|
||||
yield Button("RichPixels", id="poster_richpixels", variant="primary" if settings.get("poster") == "richpixels" else "default")
|
||||
|
||||
# HEVC quality selection
|
||||
yield Static("HEVC Encoding Quality (for conversions):", classes="label")
|
||||
@@ -360,15 +361,17 @@ Configure application settings.
|
||||
cat_btn.variant = "primary" if mode == "catalog" else "default"
|
||||
elif event.button.id.startswith("poster_"):
|
||||
# Toggle poster buttons
|
||||
poster_mode = event.button.id.split("_")[1]
|
||||
poster_mode = event.button.id.split("_", 1)[1] # Use split with maxsplit=1 to handle "richpixels"
|
||||
self.app.settings.set("poster", poster_mode) # type: ignore
|
||||
# Update button variants
|
||||
no_btn = self.query_one("#poster_no", Button)
|
||||
pseudo_btn = self.query_one("#poster_pseudo", Button)
|
||||
viu_btn = self.query_one("#poster_viu", Button)
|
||||
richpixels_btn = self.query_one("#poster_richpixels", Button)
|
||||
no_btn.variant = "primary" if poster_mode == "no" else "default"
|
||||
pseudo_btn.variant = "primary" if poster_mode == "pseudo" else "default"
|
||||
viu_btn.variant = "primary" if poster_mode == "viu" else "default"
|
||||
richpixels_btn.variant = "primary" if poster_mode == "richpixels" else "default"
|
||||
elif event.button.id.startswith("hevc_crf_"):
|
||||
# Toggle HEVC CRF buttons
|
||||
crf_value = int(event.button.id.split("_")[-1])
|
||||
|
||||
@@ -9,7 +9,7 @@ class Settings:
|
||||
|
||||
DEFAULTS = {
|
||||
"mode": "technical", # "technical" or "catalog"
|
||||
"poster": "no", # "no", "pseudo", "viu"
|
||||
"poster": "no", # "no", "pseudo" (ASCII art), "viu", "richpixels"
|
||||
"hevc_crf": 23, # HEVC quality: 18=visually lossless, 23=high quality, 28=balanced
|
||||
"hevc_preset": "fast", # HEVC speed: ultrafast, veryfast, faster, fast, medium, slow
|
||||
"cache_ttl_extractors": 21600, # 6 hours in seconds
|
||||
|
||||
@@ -369,7 +369,7 @@ class MediaPanelProperties:
|
||||
|
||||
@property
|
||||
@text_decorators.blue()
|
||||
@conditional_decorators.wrap("Title: ")
|
||||
@conditional_decorators.wrap(left=" ")
|
||||
@text_decorators.yellow()
|
||||
@conditional_decorators.default("<None>")
|
||||
def media_title(self) -> str:
|
||||
|
||||
19
renamer/views/posters/__init__.py
Normal file
19
renamer/views/posters/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Poster rendering views.
|
||||
|
||||
This package provides different rendering engines for movie posters:
|
||||
- ASCII art (pseudo graphics)
|
||||
- viu (terminal image viewer)
|
||||
- rich-pixels (Rich library integration)
|
||||
"""
|
||||
|
||||
from .base import PosterRenderer
|
||||
from .ascii_renderer import AsciiPosterRenderer
|
||||
from .viu_renderer import ViuPosterRenderer
|
||||
from .richpixels_renderer import RichPixelsPosterRenderer
|
||||
|
||||
__all__ = [
|
||||
'PosterRenderer',
|
||||
'AsciiPosterRenderer',
|
||||
'ViuPosterRenderer',
|
||||
'RichPixelsPosterRenderer',
|
||||
]
|
||||
80
renamer/views/posters/ascii_renderer.py
Normal file
80
renamer/views/posters/ascii_renderer.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""ASCII art poster renderer."""
|
||||
|
||||
from .base import PosterRenderer
|
||||
|
||||
|
||||
class AsciiPosterRenderer(PosterRenderer):
|
||||
"""Render posters as ASCII art using PIL."""
|
||||
|
||||
def render(self, image_path: str, width: int = 35) -> str:
|
||||
"""Render poster as ASCII art.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image
|
||||
width: Width in characters (default: 35)
|
||||
|
||||
Returns:
|
||||
ASCII art representation of the poster
|
||||
"""
|
||||
is_valid, error_msg = self.validate_image(image_path)
|
||||
if not is_valid:
|
||||
return error_msg
|
||||
|
||||
is_available, msg = self.is_available()
|
||||
if not is_available:
|
||||
return msg
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageEnhance
|
||||
|
||||
# Open image
|
||||
img = Image.open(image_path)
|
||||
|
||||
# Enhance contrast for better detail
|
||||
enhancer = ImageEnhance.Contrast(img)
|
||||
img = enhancer.enhance(1.3)
|
||||
|
||||
# Convert to grayscale and resize
|
||||
# Using provided width, height calculated to maintain aspect ratio
|
||||
img = img.convert('L').resize((width, width), Image.Resampling.LANCZOS)
|
||||
|
||||
# Extended ASCII characters from darkest to lightest (more gradient levels)
|
||||
# Using characters with different visual density for better detail
|
||||
ascii_chars = '$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,"^`\'. '
|
||||
|
||||
# Convert to ASCII
|
||||
pixels = img.getdata()
|
||||
img_width, height = img.size
|
||||
|
||||
ascii_art = []
|
||||
for y in range(0, height, 2): # Skip every other row for aspect ratio correction
|
||||
row = []
|
||||
for x in range(img_width):
|
||||
# Average of two rows for better aspect ratio
|
||||
pixel1 = pixels[y * img_width + x] if y < height else 255
|
||||
pixel2 = pixels[(y + 1) * img_width + x] if y + 1 < height else 255
|
||||
avg = (pixel1 + pixel2) // 2
|
||||
|
||||
# Map pixel brightness to character
|
||||
# Invert: 0 (black) -> dark char, 255 (white) -> light char
|
||||
char_index = (255 - avg) * (len(ascii_chars) - 1) // 255
|
||||
char = ascii_chars[char_index]
|
||||
row.append(char)
|
||||
ascii_art.append(''.join(row))
|
||||
|
||||
return '\n'.join(ascii_art)
|
||||
|
||||
except Exception as e:
|
||||
return f"Failed to display image: {e}\nPoster at: {image_path}"
|
||||
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
"""Check if PIL is available.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, message)
|
||||
"""
|
||||
try:
|
||||
import PIL
|
||||
return True, ""
|
||||
except ImportError:
|
||||
return False, "PIL not available for ASCII art rendering\nInstall with: pip install Pillow"
|
||||
43
renamer/views/posters/base.py
Normal file
43
renamer/views/posters/base.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Base class for poster renderers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import os
|
||||
|
||||
|
||||
class PosterRenderer(ABC):
|
||||
"""Abstract base class for poster rendering implementations."""
|
||||
|
||||
@abstractmethod
|
||||
def render(self, image_path: str, width: int = 40) -> str:
|
||||
"""Render a poster image to a string.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image file
|
||||
width: Desired width in characters
|
||||
|
||||
Returns:
|
||||
Rendered poster as a string
|
||||
"""
|
||||
pass
|
||||
|
||||
def validate_image(self, image_path: str) -> tuple[bool, str]:
|
||||
"""Validate that image file exists.
|
||||
|
||||
Args:
|
||||
image_path: Path to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not os.path.exists(image_path):
|
||||
return False, f"Image file not found: {image_path}"
|
||||
return True, ""
|
||||
|
||||
@abstractmethod
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
"""Check if this renderer is available on the system.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, message)
|
||||
"""
|
||||
pass
|
||||
51
renamer/views/posters/richpixels_renderer.py
Normal file
51
renamer/views/posters/richpixels_renderer.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Rich-pixels renderer for high-quality terminal image display."""
|
||||
|
||||
from .base import PosterRenderer
|
||||
from typing import Union
|
||||
|
||||
|
||||
class RichPixelsPosterRenderer(PosterRenderer):
|
||||
"""Render posters using rich-pixels library for high-quality display."""
|
||||
|
||||
def render(self, image_path: str, width: int = 40) -> Union[str, object]:
|
||||
"""Render poster using rich-pixels.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image
|
||||
width: Width in characters (default: 40)
|
||||
|
||||
Returns:
|
||||
Rich Pixels object (Renderable) or error string
|
||||
"""
|
||||
is_valid, error_msg = self.validate_image(image_path)
|
||||
if not is_valid:
|
||||
return error_msg
|
||||
|
||||
is_available, msg = self.is_available()
|
||||
if not is_available:
|
||||
return msg
|
||||
|
||||
try:
|
||||
from rich_pixels import Pixels
|
||||
|
||||
# Create a Pixels object from the image
|
||||
# Return the Pixels object directly - it's a Rich Renderable
|
||||
# that Textual can display natively
|
||||
pixels = Pixels.from_image_path(image_path, resize=(width * 2, width * 2))
|
||||
|
||||
return pixels
|
||||
|
||||
except Exception as e:
|
||||
return f"Failed to display image with rich-pixels: {e}\nPoster at: {image_path}"
|
||||
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
"""Check if rich-pixels is installed.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, message)
|
||||
"""
|
||||
try:
|
||||
import rich_pixels
|
||||
return True, ""
|
||||
except ImportError:
|
||||
return False, "rich-pixels not installed. Install with: pip install rich-pixels"
|
||||
55
renamer/views/posters/viu_renderer.py
Normal file
55
renamer/views/posters/viu_renderer.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Viu terminal image viewer renderer."""
|
||||
|
||||
import subprocess
|
||||
import shutil
|
||||
from .base import PosterRenderer
|
||||
|
||||
|
||||
class ViuPosterRenderer(PosterRenderer):
|
||||
"""Render posters using viu terminal image viewer."""
|
||||
|
||||
def render(self, image_path: str, width: int = 40) -> str:
|
||||
"""Render poster using viu.
|
||||
|
||||
Args:
|
||||
image_path: Path to the poster image
|
||||
width: Width in characters (default: 40)
|
||||
|
||||
Returns:
|
||||
Viu-rendered image with ANSI escape sequences
|
||||
"""
|
||||
is_valid, error_msg = self.validate_image(image_path)
|
||||
if not is_valid:
|
||||
return error_msg
|
||||
|
||||
is_available, msg = self.is_available()
|
||||
if not is_available:
|
||||
return msg
|
||||
|
||||
try:
|
||||
# Run viu to render the image
|
||||
# -w <width>: width in characters
|
||||
# -t: transparent background
|
||||
result = subprocess.run(
|
||||
['viu', '-w', str(width), '-t', image_path],
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
# Decode bytes output, preserving ANSI escape sequences
|
||||
return result.stdout.decode('utf-8', errors='replace')
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
stderr_msg = e.stderr.decode('utf-8', errors='replace') if e.stderr else 'Unknown error'
|
||||
return f"Failed to render image with viu: {stderr_msg}\nPoster at: {image_path}"
|
||||
except Exception as e:
|
||||
return f"Failed to display image: {e}\nPoster at: {image_path}"
|
||||
|
||||
def is_available(self) -> tuple[bool, str]:
|
||||
"""Check if viu is installed.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, message)
|
||||
"""
|
||||
if shutil.which('viu'):
|
||||
return True, ""
|
||||
return False, "viu not installed. Install with: cargo install viu"
|
||||
5
renamer/widgets/__init__.py
Normal file
5
renamer/widgets/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Custom Textual widgets."""
|
||||
|
||||
from .poster_widget import PosterWidget
|
||||
|
||||
__all__ = ['PosterWidget']
|
||||
29
renamer/widgets/poster_widget.py
Normal file
29
renamer/widgets/poster_widget.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Custom widget for rendering poster images."""
|
||||
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
from rich.console import RenderableType
|
||||
from typing import Union
|
||||
|
||||
|
||||
class PosterWidget(Static):
|
||||
"""Widget optimized for displaying poster images with Rich renderables.
|
||||
|
||||
This widget properly handles both string content and Rich renderables
|
||||
(like Pixels from rich-pixels) without escaping or markup processing.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize poster widget with markup disabled."""
|
||||
# Force markup=False to prevent text processing
|
||||
kwargs['markup'] = False
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def update_poster(self, renderable: Union[str, RenderableType]) -> None:
|
||||
"""Update poster display with new content.
|
||||
|
||||
Args:
|
||||
renderable: String or Rich Renderable object to display
|
||||
"""
|
||||
# Directly update with the renderable - Textual handles Rich renderables natively
|
||||
self.update(renderable if renderable else "")
|
||||
Reference in New Issue
Block a user