feat: Add HEVC encoding options and support for MPG/MPEG formats in conversion
This commit is contained in:
8
ToDo.md
8
ToDo.md
@@ -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
BIN
dist/renamer-0.7.5-py3-none-any.whl
vendored
Normal file
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user