feat: Add HEVC encoding options and support for MPG/MPEG formats in conversion

This commit is contained in:
sHa
2026-01-04 12:33:36 +00:00
parent 3902dae435
commit ae44976bcc
10 changed files with 326 additions and 90 deletions

View File

@@ -60,6 +60,14 @@ This file tracks future feature enhancements and improvements.
- Support for fanart/backdrops - Support for fanart/backdrops
- Poster cache management UI - Poster cache management UI
- [ ] **Dedicated Poster Window with Real Image Support**
- Create separate panel/window for poster display in catalog mode
- Display actual poster images (not ASCII art) using terminal graphics protocols
- Support for Kitty graphics protocol, iTerm2 inline images, or Sixel
- Configurable poster size with smaller font rendering
- Side-by-side layout: metadata (60%) + poster (40%)
- Higher resolution ASCII art as fallback (100+ chars with extended gradient)
- [ ] **Progress Indicators** - [ ] **Progress Indicators**
- Show scan progress - Show scan progress
- Batch operation progress bars - Batch operation progress bars

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

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "renamer" name = "renamer"
version = "0.7.4" version = "0.7.5"
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"

View File

@@ -69,7 +69,7 @@ class AppCommandProvider(Provider):
("scan", "Scan Directory", "Scan current directory for media files (s)"), ("scan", "Scan Directory", "Scan current directory for media files (s)"),
("refresh", "Refresh File", "Refresh metadata for selected file (f)"), ("refresh", "Refresh File", "Refresh metadata for selected file (f)"),
("rename", "Rename File", "Rename the selected file (r)"), ("rename", "Rename File", "Rename the selected file (r)"),
("convert", "Convert AVI to MKV", "Convert AVI file to MKV container with metadata (c)"), ("convert", "Convert to MKV", "Convert AVI/MPG/MPEG file to MKV container with metadata (c)"),
("delete", "Delete File", "Delete the selected file (d)"), ("delete", "Delete File", "Delete the selected file (d)"),
("toggle_mode", "Toggle Display Mode", "Switch between technical and catalog view (m)"), ("toggle_mode", "Toggle Display Mode", "Switch between technical and catalog view (m)"),
("expand", "Toggle Tree Expansion", "Expand or collapse all tree nodes (p)"), ("expand", "Toggle Tree Expansion", "Expand or collapse all tree nodes (p)"),
@@ -105,7 +105,7 @@ class RenamerApp(App):
("s", "scan", "Scan"), ("s", "scan", "Scan"),
("f", "refresh", "Refresh"), ("f", "refresh", "Refresh"),
("r", "rename", "Rename"), ("r", "rename", "Rename"),
("c", "convert", "Convert AVI→MKV"), ("c", "convert", "Convert to MKV"),
("d", "delete", "Delete"), ("d", "delete", "Delete"),
("p", "expand", "Toggle Tree"), ("p", "expand", "Toggle Tree"),
("m", "toggle_mode", "Toggle Mode"), ("m", "toggle_mode", "Toggle Mode"),
@@ -390,7 +390,7 @@ By Category:"""
self.notify("Proposed name is the same as current name; no rename needed.", severity="information", timeout=3) self.notify("Proposed name is the same as current name; no rename needed.", severity="information", timeout=3)
async def action_convert(self): async def action_convert(self):
"""Convert AVI file to MKV with metadata preservation.""" """Convert AVI/MPG/MPEG file to MKV with metadata preservation."""
tree = self.query_one("#file_tree", Tree) tree = self.query_one("#file_tree", Tree)
node = tree.cursor_node node = tree.cursor_node
@@ -403,7 +403,7 @@ By Category:"""
# Check if file can be converted # Check if file can be converted
if not conversion_service.can_convert(file_path): if not conversion_service.can_convert(file_path):
self.notify("Only AVI files can be converted to MKV", severity="error", timeout=3) self.notify("Only AVI, MPG, and MPEG files can be converted to MKV", severity="error", timeout=3)
return return
# Create extractor for metadata # Create extractor for metadata

View File

@@ -7,7 +7,7 @@ and their aliases for detection in filenames.
SPECIAL_EDITIONS = { SPECIAL_EDITIONS = {
"Theatrical Cut": ["Theatrical Cut"], "Theatrical Cut": ["Theatrical Cut"],
"Director's Cut": ["Director's Cut", "Director Cut"], "Director's Cut": ["Director's Cut", "Director Cut"],
"Extended Edition": ["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"],
"Collector's Edition": ["Collector's Edition"], "Collector's Edition": ["Collector's Edition"],
"Criterion Collection": ["Criterion Collection"], "Criterion Collection": ["Criterion Collection"],

View File

@@ -43,4 +43,14 @@ MEDIA_TYPES = {
"m4v": {"description": "MPEG-4 video", "meta_type": "MP4", "mime": "video/mp4"}, "m4v": {"description": "MPEG-4 video", "meta_type": "MP4", "mime": "video/mp4"},
"3gp": {"description": "3GPP multimedia", "meta_type": "MP4", "mime": "video/3gpp"}, "3gp": {"description": "3GPP multimedia", "meta_type": "MP4", "mime": "video/3gpp"},
"ogv": {"description": "Ogg Video", "meta_type": "Ogg", "mime": "video/ogg"}, "ogv": {"description": "Ogg Video", "meta_type": "Ogg", "mime": "video/ogg"},
"mpg": {
"description": "MPEG video",
"meta_type": "MPEG-PS",
"mime": "video/mpeg",
},
"mpeg": {
"description": "MPEG video",
"meta_type": "MPEG-PS",
"mime": "video/mpeg",
},
} }

View File

@@ -308,6 +308,21 @@ Configure application settings.
yield Button("Pseudo", id="poster_pseudo", variant="primary" if settings.get("poster") == "pseudo" else "default") yield Button("Pseudo", 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")
# HEVC quality selection
yield Static("HEVC Encoding Quality (for conversions):", classes="label")
with Horizontal():
yield Button("CRF 18 (Visually Lossless)", id="hevc_crf_18", variant="primary" if settings.get("hevc_crf") == 18 else "default")
yield Button("CRF 23 (High Quality)", id="hevc_crf_23", variant="primary" if settings.get("hevc_crf") == 23 else "default")
yield Button("CRF 28 (Balanced)", id="hevc_crf_28", variant="primary" if settings.get("hevc_crf") == 28 else "default")
# HEVC preset selection
yield Static("HEVC Encoding Speed (faster = lower quality/smaller file):", classes="label")
with Horizontal():
yield Button("Ultrafast", id="hevc_preset_ultrafast", variant="primary" if settings.get("hevc_preset") == "ultrafast" else "default")
yield Button("Veryfast", id="hevc_preset_veryfast", variant="primary" if settings.get("hevc_preset") == "veryfast" else "default")
yield Button("Fast", id="hevc_preset_fast", variant="primary" if settings.get("hevc_preset") == "fast" else "default")
yield Button("Medium", id="hevc_preset_medium", variant="primary" if settings.get("hevc_preset") == "medium" else "default")
# TTL inputs # TTL inputs
yield Static("Cache TTL - Extractors (hours):", classes="label") yield Static("Cache TTL - Extractors (hours):", classes="label")
yield Input(value=str(settings.get("cache_ttl_extractors") // 3600), id="ttl_extractors", classes="input_field") yield Input(value=str(settings.get("cache_ttl_extractors") // 3600), id="ttl_extractors", classes="input_field")
@@ -348,6 +363,30 @@ Configure application settings.
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"
elif event.button.id.startswith("hevc_crf_"):
# Toggle HEVC CRF buttons
crf_value = int(event.button.id.split("_")[-1])
self.app.settings.set("hevc_crf", crf_value) # type: ignore
# Update button variants
crf18_btn = self.query_one("#hevc_crf_18", Button)
crf23_btn = self.query_one("#hevc_crf_23", Button)
crf28_btn = self.query_one("#hevc_crf_28", Button)
crf18_btn.variant = "primary" if crf_value == 18 else "default"
crf23_btn.variant = "primary" if crf_value == 23 else "default"
crf28_btn.variant = "primary" if crf_value == 28 else "default"
elif event.button.id.startswith("hevc_preset_"):
# Toggle HEVC preset buttons
preset_value = event.button.id.split("_")[-1]
self.app.settings.set("hevc_preset", preset_value) # type: ignore
# Update button variants
ultrafast_btn = self.query_one("#hevc_preset_ultrafast", Button)
veryfast_btn = self.query_one("#hevc_preset_veryfast", Button)
fast_btn = self.query_one("#hevc_preset_fast", Button)
medium_btn = self.query_one("#hevc_preset_medium", Button)
ultrafast_btn.variant = "primary" if preset_value == "ultrafast" else "default"
veryfast_btn.variant = "primary" if preset_value == "veryfast" else "default"
fast_btn.variant = "primary" if preset_value == "fast" else "default"
medium_btn.variant = "primary" if preset_value == "medium" else "default"
def save_settings(self): def save_settings(self):
try: try:
@@ -409,7 +448,7 @@ class ConvertConfirmScreen(Screen):
def compose(self): def compose(self):
from .formatters.text_formatter import TextFormatter from .formatters.text_formatter import TextFormatter
title_text = f"{TextFormatter.bold(TextFormatter.yellow('AVI → MKV CONVERSION'))}" title_text = f"{TextFormatter.bold(TextFormatter.yellow('MKV CONVERSION'))}"
# Build details # Build details
details_lines = [ details_lines = [
@@ -438,30 +477,50 @@ class ConvertConfirmScreen(Screen):
details_text = "\n".join(details_lines) details_text = "\n".join(details_lines)
warning_text = f""" # Get HEVC CRF from settings
{TextFormatter.bold(TextFormatter.red("Fast remux - streams will be copied without re-encoding"))} settings = self.app.settings # type: ignore
{TextFormatter.yellow("This operation may take a few seconds to minutes depending on file size")} hevc_crf = settings.get("hevc_crf", 23)
Do you want to proceed with conversion? info_text = f"""
{TextFormatter.bold('Choose conversion mode:')}
{TextFormatter.green('Copy Mode')} - Fast remux, no re-encoding (seconds to minutes)
{TextFormatter.yellow(f'HEVC Mode')} - Re-encode to H.265, CRF {hevc_crf} quality (minutes to hours)
{TextFormatter.grey('(Change quality in Settings with Ctrl+S)')}
""".strip() """.strip()
with Center(): with Center():
with Vertical(): with Vertical():
yield Static(title_text, id="convert_content", markup=True) yield Static(title_text, id="convert_content", markup=True)
yield Static(details_text, id="conversion_details", markup=True) yield Static(details_text, id="conversion_details", markup=True)
yield Static(warning_text, id="warning_content", markup=True) yield Static(info_text, id="info_text", markup=True)
with Horizontal(id="buttons"): with Horizontal(id="buttons"):
yield Button("Convert (y)", id="convert", variant="success") yield Button("Convert Copy (c)", id="convert_copy", variant="success")
yield Button("Convert HEVC (e)", id="convert_hevc", variant="primary")
yield Button("Cancel (n)", id="cancel", variant="error") yield Button("Cancel (n)", id="cancel", variant="error")
def on_mount(self): def on_mount(self):
self.set_focus(self.query_one("#convert")) self.set_focus(self.query_one("#convert_copy"))
def on_button_pressed(self, event): def on_button_pressed(self, event):
if event.button.id == "convert": if event.button.id == "convert_copy":
# Start conversion self._do_conversion(encode_hevc=False)
elif event.button.id == "convert_hevc":
self._do_conversion(encode_hevc=True)
elif event.button.id == "cancel":
self.app.pop_screen() # type: ignore
def _do_conversion(self, encode_hevc: bool):
"""Start conversion with the specified encoding mode."""
app = self.app # type: ignore app = self.app # type: ignore
app.notify("Starting conversion...", severity="information", timeout=2) settings = app.settings
# Get CRF and preset from settings if using HEVC
crf = settings.get("hevc_crf", 23) if encode_hevc else 18
preset = settings.get("hevc_preset", "fast") if encode_hevc else "medium"
mode_str = f"HEVC CRF {crf} ({preset})" if encode_hevc else "Copy"
app.notify(f"Starting conversion ({mode_str})...", severity="information", timeout=2)
def do_conversion(): def do_conversion():
from .services.conversion_service import ConversionService from .services.conversion_service import ConversionService
@@ -469,11 +528,15 @@ Do you want to proceed with conversion?
import logging import logging
conversion_service = ConversionService() conversion_service = ConversionService()
logging.info(f"Starting conversion of {self.avi_path}") logging.info(f"Starting conversion of {self.avi_path} with encode_hevc={encode_hevc}, crf={crf}, preset={preset}")
logging.info(f"CPU architecture: {conversion_service.cpu_arch}")
success, message = conversion_service.convert_avi_to_mkv( success, message = conversion_service.convert_avi_to_mkv(
self.avi_path, self.avi_path,
extractor=self.extractor extractor=self.extractor,
encode_hevc=encode_hevc,
crf=crf,
preset=preset
) )
logging.info(f"Conversion result: success={success}, message={message}") logging.info(f"Conversion result: success={success}, message={message}")
@@ -506,15 +569,14 @@ Do you want to proceed with conversion?
# Close the screen # Close the screen
self.app.pop_screen() # type: ignore self.app.pop_screen() # type: ignore
else:
# Cancel
self.app.pop_screen() # type: ignore
def on_key(self, event): def on_key(self, event):
if event.key == "y": if event.key == "c":
# Simulate convert button press # Copy mode
convert_button = self.query_one("#convert") self._do_conversion(encode_hevc=False)
self.on_button_pressed(type('Event', (), {'button': convert_button})()) elif event.key == "e":
# HEVC mode
self._do_conversion(encode_hevc=True)
elif event.key == "n" or event.key == "escape": elif event.key == "n" or event.key == "escape":
self.app.pop_screen() # type: ignore self.app.pop_screen() # type: ignore

View File

@@ -10,6 +10,7 @@ This service manages the process of converting AVI files to MKV container:
import logging import logging
import subprocess import subprocess
import platform
from pathlib import Path from pathlib import Path
from typing import Optional, List, Dict, Tuple from typing import Optional, List, Dict, Tuple
@@ -45,29 +46,148 @@ class ConversionService:
def __init__(self): def __init__(self):
"""Initialize the conversion service.""" """Initialize the conversion service."""
logger.debug("ConversionService initialized") self.cpu_arch = self._detect_cpu_architecture()
logger.debug(f"ConversionService initialized with CPU architecture: {self.cpu_arch}")
def _detect_cpu_architecture(self) -> str:
"""Detect CPU architecture for optimization.
Returns:
Architecture string: 'x86_64', 'arm64', 'aarch64', or 'unknown'
"""
machine = platform.machine().lower()
# Try to get more specific CPU info
try:
if machine in ['x86_64', 'amd64']:
# Check for Intel vs AMD
with open('/proc/cpuinfo', 'r') as f:
cpuinfo = f.read().lower()
if 'intel' in cpuinfo or 'xeon' in cpuinfo:
return 'intel_x86_64'
elif 'amd' in cpuinfo:
return 'amd_x86_64'
else:
return 'x86_64'
elif machine in ['arm64', 'aarch64']:
# Check for specific ARM chips
with open('/proc/cpuinfo', 'r') as f:
cpuinfo = f.read().lower()
if 'rk3588' in cpuinfo or 'rockchip' in cpuinfo:
return 'arm64_rk3588'
else:
return 'arm64'
except Exception as e:
logger.debug(f"Could not read /proc/cpuinfo: {e}")
return machine
def _get_x265_params(self, preset: str = 'medium') -> str:
"""Get optimized x265 parameters based on CPU architecture.
Args:
preset: Encoding preset (ultrafast, superfast, veryfast, faster, fast, medium, slow)
Returns:
x265 parameter string optimized for the detected CPU
"""
# Base parameters for quality
base_params = [
'profile=main10',
'level=4.1',
]
# CPU-specific optimizations
if self.cpu_arch in ['intel_x86_64', 'amd_x86_64', 'x86_64']:
# Intel Xeon / AMD optimization
# Enable assembly optimizations and threading
cpu_params = [
'pools=+', # Enable thread pools
'frame-threads=4', # Parallel frame encoding (adjust based on cores)
'lookahead-threads=2', # Lookahead threads
'asm=auto', # Enable CPU-specific assembly optimizations
]
# For faster encoding on servers
if preset in ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast']:
cpu_params.extend([
'ref=2', # Fewer reference frames for speed
'bframes=3', # Fewer B-frames
'me=1', # Faster motion estimation (DIA)
'subme=1', # Faster subpixel refinement
'rd=2', # Faster RD refinement
])
else: # medium or slow
cpu_params.extend([
'ref=3',
'bframes=4',
'me=2', # HEX motion estimation
'subme=2',
'rd=3',
])
elif self.cpu_arch in ['arm64_rk3588', 'arm64', 'aarch64']:
# ARM64 / RK3588 optimization
# RK3588 has 4x Cortex-A76 + 4x Cortex-A55
cpu_params = [
'pools=+',
'frame-threads=4', # Use big cores
'lookahead-threads=1', # Lighter lookahead for ARM
'asm=auto', # Enable NEON optimizations
]
# ARM is slower, so optimize more aggressively for speed
if preset in ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast']:
cpu_params.extend([
'ref=1', # Minimal reference frames
'bframes=2',
'me=0', # Full search (faster on ARM)
'subme=0',
'rd=1',
'weightp=0', # Disable weighted prediction for speed
'weightb=0',
])
else: # medium
cpu_params.extend([
'ref=2',
'bframes=3',
'me=1',
'subme=1',
'rd=2',
])
else:
# Generic/unknown architecture - conservative settings
cpu_params = [
'pools=+',
'frame-threads=2',
'ref=2',
'bframes=3',
]
return ':'.join(base_params + cpu_params)
def can_convert(self, file_path: Path) -> bool: def can_convert(self, file_path: Path) -> bool:
"""Check if a file can be converted (is AVI). """Check if a file can be converted (is AVI, MPG, or MPEG).
Args: Args:
file_path: Path to the file to check file_path: Path to the file to check
Returns: Returns:
True if file is AVI and can be converted True if file is AVI, MPG, or MPEG and can be converted
""" """
if not file_path.exists() or not file_path.is_file(): if not file_path.exists() or not file_path.is_file():
return False return False
return file_path.suffix.lower() == '.avi' return file_path.suffix.lower() in {'.avi', '.mpg', '.mpeg'}
def find_subtitle_files(self, avi_path: Path) -> List[Path]: def find_subtitle_files(self, video_path: Path) -> List[Path]:
"""Find subtitle files near the AVI file. """Find subtitle files near the video file.
Looks for subtitle files with the same basename in the same directory. Looks for subtitle files with the same basename in the same directory.
Args: Args:
avi_path: Path to the AVI file video_path: Path to the video file
Returns: Returns:
List of Path objects for found subtitle files List of Path objects for found subtitle files
@@ -77,8 +197,8 @@ class ConversionService:
[Path("/media/movie.srt"), Path("/media/movie.eng.srt")] [Path("/media/movie.srt"), Path("/media/movie.eng.srt")]
""" """
subtitle_files = [] subtitle_files = []
base_name = avi_path.stem # filename without extension base_name = video_path.stem # filename without extension
directory = avi_path.parent directory = video_path.parent
# Look for files with same base name and subtitle extensions # Look for files with same base name and subtitle extensions
for sub_ext in self.SUBTITLE_EXTENSIONS: for sub_ext in self.SUBTITLE_EXTENSIONS:
@@ -93,7 +213,7 @@ class ConversionService:
if sub_file not in subtitle_files: if sub_file not in subtitle_files:
subtitle_files.append(sub_file) subtitle_files.append(sub_file)
logger.debug(f"Found {len(subtitle_files)} subtitle files for {avi_path.name}") logger.debug(f"Found {len(subtitle_files)} subtitle files for {video_path.name}")
return subtitle_files return subtitle_files
def map_audio_languages( def map_audio_languages(
@@ -142,35 +262,41 @@ class ConversionService:
def build_ffmpeg_command( def build_ffmpeg_command(
self, self,
avi_path: Path, source_path: Path,
mkv_path: Path, mkv_path: Path,
audio_languages: List[Optional[str]], audio_languages: List[Optional[str]],
subtitle_files: List[Path] subtitle_files: List[Path],
encode_hevc: bool = False,
crf: int = 18,
preset: str = 'medium'
) -> List[str]: ) -> List[str]:
"""Build ffmpeg command for AVI to MKV conversion. """Build ffmpeg command for video to MKV conversion.
Creates a command that: Creates a command that:
- Copies video and audio streams (no re-encoding) - Copies video and audio streams (no re-encoding) OR
- Encodes video to HEVC with high quality settings
- Sets audio language metadata - Sets audio language metadata
- Includes external subtitle files - Includes external subtitle files
- Sets MKV title from filename - Sets MKV title from filename
Args: Args:
avi_path: Source AVI file source_path: Source video file (AVI, MPG, or MPEG)
mkv_path: Destination MKV file mkv_path: Destination MKV file
audio_languages: Language codes for each audio track audio_languages: Language codes for each audio track
subtitle_files: List of subtitle files to include subtitle_files: List of subtitle files to include
encode_hevc: If True, encode video to HEVC instead of copying
crf: Constant Rate Factor for HEVC (18=visually lossless, 23=high quality default)
Returns: Returns:
List of command arguments for subprocess List of command arguments for subprocess
""" """
cmd = ['ffmpeg'] cmd = ['ffmpeg']
# Add flags to fix timestamp issues in AVI files # Add flags to fix timestamp issues (particularly for AVI files)
cmd.extend(['-fflags', '+genpts']) cmd.extend(['-fflags', '+genpts'])
# Input file # Input file
cmd.extend(['-i', str(avi_path)]) cmd.extend(['-i', str(source_path)])
# Add subtitle files as inputs # Add subtitle files as inputs
for sub_file in subtitle_files: for sub_file in subtitle_files:
@@ -186,7 +312,24 @@ class ConversionService:
for i in range(len(subtitle_files)): for i in range(len(subtitle_files)):
cmd.extend(['-map', f'{i+1}:s:0']) cmd.extend(['-map', f'{i+1}:s:0'])
# Copy codecs (no re-encoding) # Video codec settings
if encode_hevc:
# HEVC encoding with CPU-optimized parameters
cmd.extend(['-c:v', 'libx265'])
cmd.extend(['-crf', str(crf)])
# Use specified preset
cmd.extend(['-preset', preset])
# 10-bit encoding for better quality (if source supports it)
cmd.extend(['-pix_fmt', 'yuv420p10le'])
# CPU-optimized x265 parameters
x265_params = self._get_x265_params(preset)
cmd.extend(['-x265-params', x265_params])
# Copy audio streams (no audio re-encoding)
cmd.extend(['-c:a', 'copy'])
# Copy subtitle streams
cmd.extend(['-c:s', 'copy'])
else:
# Copy all streams (no re-encoding)
cmd.extend(['-c', 'copy']) cmd.extend(['-c', 'copy'])
# Set audio language metadata # Set audio language metadata
@@ -195,7 +338,7 @@ class ConversionService:
cmd.extend([f'-metadata:s:a:{i}', f'language={lang}']) cmd.extend([f'-metadata:s:a:{i}', f'language={lang}'])
# Set title metadata from filename # Set title metadata from filename
title = avi_path.stem title = source_path.stem
cmd.extend(['-metadata', f'title={title}']) cmd.extend(['-metadata', f'title={title}'])
# Output file # Output file
@@ -209,28 +352,36 @@ class ConversionService:
avi_path: Path, avi_path: Path,
extractor: Optional[MediaExtractor] = None, extractor: Optional[MediaExtractor] = None,
output_path: Optional[Path] = None, output_path: Optional[Path] = None,
dry_run: bool = False dry_run: bool = False,
encode_hevc: bool = False,
crf: int = 18,
preset: str = 'medium'
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
"""Convert AVI file to MKV with metadata preservation. """Convert AVI/MPG/MPEG file to MKV with metadata preservation.
Args: Args:
avi_path: Source AVI file path avi_path: Source video file path (AVI, MPG, or MPEG)
extractor: Optional MediaExtractor (creates new if None) extractor: Optional MediaExtractor (creates new if None)
output_path: Optional output path (defaults to same name with .mkv) output_path: Optional output path (defaults to same name with .mkv)
dry_run: If True, build command but don't execute dry_run: If True, build command but don't execute
encode_hevc: If True, encode video to HEVC instead of copying
crf: Constant Rate Factor for HEVC (18=visually lossless, 23=high quality)
preset: x265 preset (ultrafast, veryfast, faster, fast, medium, slow)
Returns: Returns:
Tuple of (success, message) Tuple of (success, message)
Example: Example:
>>> success, msg = service.convert_avi_to_mkv( >>> success, msg = service.convert_avi_to_mkv(
... Path("/media/movie.avi") ... Path("/media/movie.avi"),
... encode_hevc=True,
... crf=18
... ) ... )
>>> print(msg) >>> print(msg)
""" """
# Validate input # Validate input
if not self.can_convert(avi_path): if not self.can_convert(avi_path):
error_msg = f"File is not AVI or doesn't exist: {avi_path}" error_msg = f"File is not a supported format (AVI/MPG/MPEG) or doesn't exist: {avi_path}"
logger.error(error_msg) logger.error(error_msg)
return False, error_msg return False, error_msg
@@ -273,7 +424,10 @@ class ConversionService:
avi_path, avi_path,
output_path, output_path,
audio_languages, audio_languages,
subtitle_files subtitle_files,
encode_hevc,
crf,
preset
) )
# Dry run mode # Dry run mode

View File

@@ -10,6 +10,8 @@ class Settings:
DEFAULTS = { DEFAULTS = {
"mode": "technical", # "technical" or "catalog" "mode": "technical", # "technical" or "catalog"
"poster": "no", # "no", "pseudo", "viu" "poster": "no", # "no", "pseudo", "viu"
"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 "cache_ttl_extractors": 21600, # 6 hours in seconds
"cache_ttl_tmdb": 21600, # 6 hours in seconds "cache_ttl_tmdb": 21600, # 6 hours in seconds
"cache_ttl_posters": 2592000, # 30 days in seconds "cache_ttl_posters": 2592000, # 30 days in seconds

2
uv.lock generated
View File

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