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]
|
[project]
|
||||||
name = "renamer"
|
name = "renamer"
|
||||||
version = "0.7.10"
|
version = "0.8.1"
|
||||||
description = "Terminal-based media file renamer and metadata viewer"
|
description = "Terminal-based media file renamer and metadata viewer"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -90,14 +90,40 @@ class AppCommandProvider(Provider):
|
|||||||
|
|
||||||
class RenamerApp(App):
|
class RenamerApp(App):
|
||||||
CSS = """
|
CSS = """
|
||||||
|
/* Default technical mode: 2 columns */
|
||||||
#left {
|
#left {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
padding: 1;
|
padding: 1;
|
||||||
}
|
}
|
||||||
#right {
|
#middle {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
padding: 1;
|
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 = [
|
BINDINGS = [
|
||||||
@@ -128,10 +154,11 @@ class RenamerApp(App):
|
|||||||
self.cache_manager = CacheManager(self.cache)
|
self.cache_manager = CacheManager(self.cache)
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Horizontal():
|
with Horizontal(id="main_container"):
|
||||||
with Container(id="left"):
|
with Container(id="left"):
|
||||||
yield Tree("Files", id="file_tree")
|
yield Tree("Files", id="file_tree")
|
||||||
with Container(id="right"):
|
# Middle container (for catalog mode info)
|
||||||
|
with Container(id="middle"):
|
||||||
with Vertical():
|
with Vertical():
|
||||||
yield LoadingIndicator(id="loading")
|
yield LoadingIndicator(id="loading")
|
||||||
with ScrollableContainer(id="details_container"):
|
with ScrollableContainer(id="details_container"):
|
||||||
@@ -142,13 +169,29 @@ class RenamerApp(App):
|
|||||||
"", id="details_catalog", markup=False
|
"", id="details_catalog", markup=False
|
||||||
)
|
)
|
||||||
yield Static("", id="proposed", markup=True)
|
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()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
loading = self.query_one("#loading", LoadingIndicator)
|
loading = self.query_one("#loading", LoadingIndicator)
|
||||||
loading.display = False
|
loading.display = False
|
||||||
|
# Apply initial layout based on mode setting
|
||||||
|
self._update_layout()
|
||||||
self.scan_files()
|
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):
|
def scan_files(self):
|
||||||
logging.info("scan_files called")
|
logging.info("scan_files called")
|
||||||
if not self.scan_dir or not self.scan_dir.exists() or not self.scan_dir.is_dir():
|
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)
|
extractor = MediaExtractor(file_path)
|
||||||
|
|
||||||
mode = self.settings.get("mode")
|
mode = self.settings.get("mode")
|
||||||
|
poster_content = ""
|
||||||
|
|
||||||
if mode == "technical":
|
if mode == "technical":
|
||||||
formatter = MediaPanelView(extractor)
|
formatter = MediaPanelView(extractor)
|
||||||
full_info = formatter.file_info_panel()
|
full_info = formatter.file_info_panel()
|
||||||
else: # catalog
|
else: # catalog
|
||||||
formatter = CatalogFormatter(extractor, self.settings)
|
formatter = CatalogFormatter(extractor, self.settings)
|
||||||
full_info = formatter.format_catalog_info()
|
full_info, poster_content = formatter.format_catalog_info()
|
||||||
|
|
||||||
# Update UI
|
# Update UI
|
||||||
self.call_later(
|
self.call_later(
|
||||||
self._update_details,
|
self._update_details,
|
||||||
full_info,
|
full_info,
|
||||||
ProposedFilenameView(extractor).rename_line_formatted(file_path),
|
ProposedFilenameView(extractor).rename_line_formatted(file_path),
|
||||||
|
poster_content,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.call_later(
|
self.call_later(
|
||||||
self._update_details,
|
self._update_details,
|
||||||
TextFormatter.red(f"Error extracting details: {str(e)}"),
|
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()
|
self._stop_loading_animation()
|
||||||
details_technical = self.query_one("#details_technical", Static)
|
details_technical = self.query_one("#details_technical", Static)
|
||||||
details_catalog = self.query_one("#details_catalog", Static)
|
details_catalog = self.query_one("#details_catalog", Static)
|
||||||
|
poster_display = self.query_one("#poster_display", Static)
|
||||||
|
|
||||||
mode = self.settings.get("mode")
|
mode = self.settings.get("mode")
|
||||||
if mode == "technical":
|
if mode == "technical":
|
||||||
details_technical.display = True
|
details_technical.display = True
|
||||||
details_catalog.display = False
|
details_catalog.display = False
|
||||||
details_technical.update(full_info)
|
details_technical.update(full_info)
|
||||||
|
poster_display.update("") # Clear poster in technical mode
|
||||||
else:
|
else:
|
||||||
details_technical.display = False
|
details_technical.display = False
|
||||||
details_catalog.display = True
|
details_catalog.display = True
|
||||||
details_catalog.update(full_info)
|
details_catalog.update(full_info)
|
||||||
|
# Update poster panel
|
||||||
|
poster_display.update(poster_content)
|
||||||
|
|
||||||
proposed = self.query_one("#proposed", Static)
|
proposed = self.query_one("#proposed", Static)
|
||||||
proposed.update(display_string)
|
proposed.update(display_string)
|
||||||
@@ -444,6 +496,8 @@ By Category:"""
|
|||||||
current_mode = self.settings.get("mode")
|
current_mode = self.settings.get("mode")
|
||||||
new_mode = "catalog" if current_mode == "technical" else "technical"
|
new_mode = "catalog" if current_mode == "technical" else "technical"
|
||||||
self.settings.set("mode", new_mode)
|
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)
|
self.notify(f"Switched to {new_mode} mode", severity="information", timeout=2)
|
||||||
# Refresh current file display if any
|
# Refresh current file display if any
|
||||||
tree = self.query_one("#file_tree", Tree)
|
tree = self.query_one("#file_tree", Tree)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ and their aliases for detection in filenames.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SPECIAL_EDITIONS = {
|
SPECIAL_EDITIONS = {
|
||||||
"Theatrical Cut": ["Theatrical Cut"],
|
"Theatrical Cut": ["Theatrical Cut", "Theatrical Reconstruction"],
|
||||||
"Director's Cut": ["Director's Cut", "Director Cut"],
|
"Director's Cut": ["Director's Cut", "Director Cut"],
|
||||||
"Extended Cut": ["Extended Cut", "Ultimate Extended Cut", "Extended Edition", "Ultimate Extended Edition"],
|
"Extended Cut": ["Extended Cut", "Ultimate Extended Cut", "Extended Edition", "Ultimate Extended Edition"],
|
||||||
"Special Edition": ["Special Edition"],
|
"Special Edition": ["Special Edition"],
|
||||||
@@ -53,7 +53,7 @@ SPECIAL_EDITIONS = {
|
|||||||
"Workprint": ["Workprint"],
|
"Workprint": ["Workprint"],
|
||||||
"Rough Cut": ["Rough Cut"],
|
"Rough Cut": ["Rough Cut"],
|
||||||
"Special Assembly Cut": ["Special Assembly 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"],
|
"Netflix Edition": ["Netflix Edition"],
|
||||||
"HBO Edition": ["HBO Edition"],
|
"HBO Edition": ["HBO Edition"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from .text_formatter import TextFormatter
|
from .text_formatter import TextFormatter
|
||||||
|
from renamer.views.posters import AsciiPosterRenderer, ViuPosterRenderer, RichPixelsPosterRenderer
|
||||||
|
from typing import Union
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
@@ -9,10 +11,14 @@ class CatalogFormatter:
|
|||||||
self.extractor = extractor
|
self.extractor = extractor
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
|
||||||
def format_catalog_info(self) -> str:
|
def format_catalog_info(self) -> tuple[str, Union[str, object]]:
|
||||||
"""Format catalog information for display"""
|
"""Format catalog information for display.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (info_text, poster_content)
|
||||||
|
poster_content can be a string or Rich Renderable object
|
||||||
|
"""
|
||||||
lines = []
|
lines = []
|
||||||
poster_output = None
|
|
||||||
|
|
||||||
# Title
|
# Title
|
||||||
title = self.extractor.get("title", "TMDB")
|
title = self.extractor.get("title", "TMDB")
|
||||||
@@ -56,23 +62,6 @@ class CatalogFormatter:
|
|||||||
if countries:
|
if countries:
|
||||||
lines.append(f"{TextFormatter.bold('Countries:')} {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
|
# Render text content with Rich markup
|
||||||
text_content = "\n\n".join(lines) if lines else "No catalog information available"
|
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)
|
console.print(text_content, markup=True)
|
||||||
rendered_text = console.file.getvalue()
|
rendered_text = console.file.getvalue()
|
||||||
|
|
||||||
# Append poster output if available
|
# Get poster separately
|
||||||
# Don't process ASCII art through console - just append it directly
|
poster_content = self.get_poster()
|
||||||
if poster_output:
|
|
||||||
return rendered_text + "\n" + poster_output
|
|
||||||
else:
|
|
||||||
return rendered_text
|
|
||||||
|
|
||||||
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.
|
"""Display poster image based on mode setting.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_path: Path to the poster image
|
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:
|
Returns:
|
||||||
Rendered poster as string
|
Rendered poster (string or Rich Renderable object)
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(image_path):
|
if not os.path.exists(image_path):
|
||||||
return f"Image file not found: {image_path}"
|
return f"Image file not found: {image_path}"
|
||||||
|
|
||||||
|
# Select renderer based on mode
|
||||||
if mode == "viu":
|
if mode == "viu":
|
||||||
return self._display_poster_viu(image_path)
|
renderer = ViuPosterRenderer()
|
||||||
elif mode == "pseudo":
|
elif mode == "pseudo":
|
||||||
return self._display_poster_pseudo(image_path)
|
renderer = AsciiPosterRenderer()
|
||||||
|
elif mode == "richpixels":
|
||||||
|
renderer = RichPixelsPosterRenderer()
|
||||||
else:
|
else:
|
||||||
return f"Unknown poster mode: {mode}"
|
return f"Unknown poster mode: {mode}"
|
||||||
|
|
||||||
def _display_poster_viu(self, image_path: str) -> str:
|
# Render the poster
|
||||||
"""Display poster using viu (not working in Textual, only in terminal)"""
|
return renderer.render(image_path, width=40)
|
||||||
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}"
|
|
||||||
@@ -7,13 +7,14 @@ class TrackFormatter:
|
|||||||
codec = track.get('codec', 'unknown')
|
codec = track.get('codec', 'unknown')
|
||||||
width = track.get('width', '?')
|
width = track.get('width', '?')
|
||||||
height = track.get('height', '?')
|
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')
|
fps = track.get('fps')
|
||||||
profile = track.get('profile')
|
profile = track.get('profile')
|
||||||
|
|
||||||
video_str = f"{codec} {width}x{height}"
|
video_str = f"{codec} {width}x{height}"
|
||||||
if bitrate:
|
if bitrate_kbps:
|
||||||
video_str += f" {bitrate}bps"
|
video_str += f" {bitrate_kbps}kbps"
|
||||||
if fps:
|
if fps:
|
||||||
video_str += f" {fps}fps"
|
video_str += f" {fps}fps"
|
||||||
if profile:
|
if profile:
|
||||||
@@ -27,12 +28,12 @@ class TrackFormatter:
|
|||||||
codec = track.get('codec', 'unknown')
|
codec = track.get('codec', 'unknown')
|
||||||
channels = track.get('channels', '?')
|
channels = track.get('channels', '?')
|
||||||
lang = track.get('language', 'und')
|
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}"
|
audio_str = f"{codec} {channels}ch {lang}"
|
||||||
if bitrate:
|
if bitrate_kbps:
|
||||||
audio_str += f" {bitrate}bps"
|
audio_str += f" {bitrate_kbps}kbps"
|
||||||
|
|
||||||
return audio_str
|
return audio_str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -311,8 +311,9 @@ Configure application settings.
|
|||||||
yield Static("Poster Display (Catalog Mode):", classes="label")
|
yield Static("Poster Display (Catalog Mode):", classes="label")
|
||||||
with Horizontal():
|
with Horizontal():
|
||||||
yield Button("No", id="poster_no", variant="primary" if settings.get("poster") == "no" else "default")
|
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("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
|
# HEVC quality selection
|
||||||
yield Static("HEVC Encoding Quality (for conversions):", classes="label")
|
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"
|
cat_btn.variant = "primary" if mode == "catalog" else "default"
|
||||||
elif event.button.id.startswith("poster_"):
|
elif event.button.id.startswith("poster_"):
|
||||||
# Toggle poster buttons
|
# 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
|
self.app.settings.set("poster", poster_mode) # type: ignore
|
||||||
# Update button variants
|
# Update button variants
|
||||||
no_btn = self.query_one("#poster_no", Button)
|
no_btn = self.query_one("#poster_no", Button)
|
||||||
pseudo_btn = self.query_one("#poster_pseudo", Button)
|
pseudo_btn = self.query_one("#poster_pseudo", Button)
|
||||||
viu_btn = self.query_one("#poster_viu", 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"
|
no_btn.variant = "primary" if poster_mode == "no" else "default"
|
||||||
pseudo_btn.variant = "primary" if poster_mode == "pseudo" else "default"
|
pseudo_btn.variant = "primary" if poster_mode == "pseudo" else "default"
|
||||||
viu_btn.variant = "primary" if poster_mode == "viu" 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_"):
|
elif event.button.id.startswith("hevc_crf_"):
|
||||||
# Toggle HEVC CRF buttons
|
# Toggle HEVC CRF buttons
|
||||||
crf_value = int(event.button.id.split("_")[-1])
|
crf_value = int(event.button.id.split("_")[-1])
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class Settings:
|
|||||||
|
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
"mode": "technical", # "technical" or "catalog"
|
"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_crf": 23, # HEVC quality: 18=visually lossless, 23=high quality, 28=balanced
|
||||||
"hevc_preset": "fast", # HEVC speed: ultrafast, veryfast, faster, fast, medium, slow
|
"hevc_preset": "fast", # HEVC speed: ultrafast, veryfast, faster, fast, medium, slow
|
||||||
"cache_ttl_extractors": 21600, # 6 hours in seconds
|
"cache_ttl_extractors": 21600, # 6 hours in seconds
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ class MediaPanelProperties:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
@text_decorators.blue()
|
@text_decorators.blue()
|
||||||
@conditional_decorators.wrap("Title: ")
|
@conditional_decorators.wrap(left=" ")
|
||||||
@text_decorators.yellow()
|
@text_decorators.yellow()
|
||||||
@conditional_decorators.default("<None>")
|
@conditional_decorators.default("<None>")
|
||||||
def media_title(self) -> str:
|
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