feat: Implement poster rendering options with ASCII, Viu, and RichPixels support

This commit is contained in:
sHa
2026-01-04 18:35:30 +00:00
parent 9b353a7e7e
commit 442bde73e5
17 changed files with 416 additions and 135 deletions

BIN
dist/renamer-0.8.1-py3-none-any.whl vendored Normal file

Binary file not shown.

View File

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

View File

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

View File

@@ -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"],
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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',
]

View 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"

View 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

View 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"

View 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"

View File

@@ -0,0 +1,5 @@
"""Custom Textual widgets."""
from .poster_widget import PosterWidget
__all__ = ['PosterWidget']

View 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 "")

2
uv.lock generated
View File

@@ -462,7 +462,7 @@ wheels = [
[[package]]
name = "renamer"
version = "0.7.10"
version = "0.8.1"
source = { editable = "." }
dependencies = [
{ name = "langcodes" },