feat: add support for different encondings
This commit is contained in:
@@ -6,8 +6,11 @@ import sys
|
|||||||
|
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
|
|
||||||
from .config import INPUT_PATH, OUTPUT_BASE, CONFIG_FILE, init_config, console
|
from .config import INPUT_PATH, OUTPUT_BASE, QUALITY, CODEC, CONFIG_FILE, init_config, console
|
||||||
from .scanner import detect_encoder, scan_disc, select_title
|
from .scanner import (
|
||||||
|
discover_encoders, print_encoder_table, select_encoder,
|
||||||
|
scan_disc, select_title, CODEC_SCENE_TAG, CODEC_DISPLAY,
|
||||||
|
)
|
||||||
from .tracks import best_tracks_per_language, print_track_tables
|
from .tracks import best_tracks_per_language, print_track_tables
|
||||||
from .naming import build_scene_name, get_source_tag, get_volume_label
|
from .naming import build_scene_name, get_source_tag, get_volume_label
|
||||||
from .encode import build_handbrake_cmd, run_encode
|
from .encode import build_handbrake_cmd, run_encode
|
||||||
@@ -16,16 +19,21 @@ from .library import list_library
|
|||||||
from .disc import check_disc_readable, unstuck_disc
|
from .disc import check_disc_readable, unstuck_disc
|
||||||
|
|
||||||
|
|
||||||
|
CODEC_CHOICES = ("hevc", "h264", "av1")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Rip DVD/Blu-ray to MKV using HandBrakeCLI (H.265 10-bit).",
|
description="Rip DVD/Blu-ray to MKV using HandBrakeCLI.",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog=(
|
epilog=(
|
||||||
"Examples:\n"
|
"Examples:\n"
|
||||||
" %(prog)s --imdb tt6977338\n"
|
" %(prog)s --imdb tt6977338\n"
|
||||||
" %(prog)s --name 'Game Night' --year 2018\n"
|
" %(prog)s --name 'Game Night' --year 2018\n"
|
||||||
|
" %(prog)s --codec av1 --quality 30\n"
|
||||||
" %(prog)s --scan-only\n"
|
" %(prog)s --scan-only\n"
|
||||||
" %(prog)s --list\n"
|
" %(prog)s --list\n"
|
||||||
|
" %(prog)s --encoders\n"
|
||||||
" %(prog)s --init-config\n"
|
" %(prog)s --init-config\n"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -40,6 +48,19 @@ def main():
|
|||||||
help="IMDB ID (e.g. tt6977338) — fetches name and year from TMDB. "
|
help="IMDB ID (e.g. tt6977338) — fetches name and year from TMDB. "
|
||||||
"Requires TMDB_API_KEY env var or config.",
|
"Requires TMDB_API_KEY env var or config.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--codec", "-c",
|
||||||
|
choices=CODEC_CHOICES,
|
||||||
|
default=CODEC,
|
||||||
|
help=f"Video codec family (default: {CODEC}).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--quality", "-q",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help=f"Constant quality / rate factor (default: {QUALITY}). "
|
||||||
|
"Lower = better quality, bigger file.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--input", "-i",
|
"--input", "-i",
|
||||||
default=INPUT_PATH,
|
default=INPUT_PATH,
|
||||||
@@ -60,6 +81,11 @@ def main():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="List all ripped movies in the output directory.",
|
help="List all ripped movies in the output directory.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--encoders",
|
||||||
|
action="store_true",
|
||||||
|
help="Show all available encoders and exit.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--check-disc",
|
"--check-disc",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -72,6 +98,9 @@ def main():
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Quality: CLI flag > config default
|
||||||
|
quality = args.quality if args.quality is not None else QUALITY
|
||||||
|
|
||||||
# ── Generate config file ─────────────────────────────────────────────
|
# ── Generate config file ─────────────────────────────────────────────
|
||||||
if args.init_config:
|
if args.init_config:
|
||||||
init_config()
|
init_config()
|
||||||
@@ -82,6 +111,12 @@ def main():
|
|||||||
list_library(args.output_base)
|
list_library(args.output_base)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# ── Show available encoders ──────────────────────────────────────────
|
||||||
|
if args.encoders:
|
||||||
|
available = discover_encoders()
|
||||||
|
print_encoder_table(available)
|
||||||
|
return
|
||||||
|
|
||||||
# ── Disc health check ────────────────────────────────────────────────
|
# ── Disc health check ────────────────────────────────────────────────
|
||||||
if args.check_disc:
|
if args.check_disc:
|
||||||
if not unstuck_disc(args.input):
|
if not unstuck_disc(args.input):
|
||||||
@@ -89,11 +124,12 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
# ── Banner ───────────────────────────────────────────────────────────
|
# ── Banner ───────────────────────────────────────────────────────────
|
||||||
|
codec_display = CODEC_DISPLAY.get(args.codec, args.codec.upper())
|
||||||
console.print()
|
console.print()
|
||||||
console.print(
|
console.print(
|
||||||
Panel(
|
Panel(
|
||||||
"[bold white]DVD / Blu-ray Ripper[/]\n"
|
f"[bold white]DVD / Blu-ray Ripper[/]\n"
|
||||||
"[dim]H.265 10-bit · HandBrakeCLI · Scene Naming[/]",
|
f"[dim]{codec_display} · HandBrakeCLI · Scene Naming[/]",
|
||||||
border_style="cyan",
|
border_style="cyan",
|
||||||
padding=(0, 2),
|
padding=(0, 2),
|
||||||
)
|
)
|
||||||
@@ -101,7 +137,9 @@ def main():
|
|||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
# ── 1. Detect encoder ────────────────────────────────────────────────
|
# ── 1. Detect encoder ────────────────────────────────────────────────
|
||||||
encoder = detect_encoder()
|
available = discover_encoders()
|
||||||
|
print_encoder_table(available)
|
||||||
|
encoder = select_encoder(available, codec=args.codec)
|
||||||
|
|
||||||
# ── 2. Scan disc ─────────────────────────────────────────────────────
|
# ── 2. Scan disc ─────────────────────────────────────────────────────
|
||||||
scan = scan_disc(args.input)
|
scan = scan_disc(args.input)
|
||||||
@@ -142,7 +180,16 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
source_tag = get_source_tag(args.input)
|
source_tag = get_source_tag(args.input)
|
||||||
scene = build_scene_name(movie_name, year, title, audio_sel, source_tag)
|
|
||||||
|
# Determine codec tag and bit depth from the selected encoder
|
||||||
|
codec_tag = CODEC_SCENE_TAG.get(args.codec, args.codec.upper())
|
||||||
|
# Check if we're using a 10-bit encoder
|
||||||
|
is_10bit = "10bit" in encoder or "10" in encoder
|
||||||
|
|
||||||
|
scene = build_scene_name(
|
||||||
|
movie_name, year, title, audio_sel, source_tag,
|
||||||
|
codec_tag=codec_tag, is_10bit=is_10bit,
|
||||||
|
)
|
||||||
|
|
||||||
# Create output directory
|
# Create output directory
|
||||||
out_dir = os.path.join(args.output_base, scene)
|
out_dir = os.path.join(args.output_base, scene)
|
||||||
@@ -163,7 +210,13 @@ def main():
|
|||||||
|
|
||||||
# ── 5. Encode ────────────────────────────────────────────────────────
|
# ── 5. Encode ────────────────────────────────────────────────────────
|
||||||
cmd = build_handbrake_cmd(
|
cmd = build_handbrake_cmd(
|
||||||
args.input, output_file, title, audio_sel, subtitle_sel, encoder,
|
input_path=args.input,
|
||||||
|
output_path=output_file,
|
||||||
|
title=title,
|
||||||
|
audio_tracks=audio_sel,
|
||||||
|
subtitle_tracks=subtitle_sel,
|
||||||
|
encoder=encoder,
|
||||||
|
quality=quality,
|
||||||
)
|
)
|
||||||
|
|
||||||
returncode = run_encode(cmd, input_path=args.input)
|
returncode = run_encode(cmd, input_path=args.input)
|
||||||
|
|||||||
@@ -47,17 +47,15 @@ DEFAULT_CONFIG = """\
|
|||||||
# output_base = "/mnt/shared/ripped"
|
# output_base = "/mnt/shared/ripped"
|
||||||
|
|
||||||
[encoder]
|
[encoder]
|
||||||
# Primary hardware encoder (set to your GPU's encoder)
|
# Video codec: "hevc", "h264", or "av1"
|
||||||
# AMD: "vce_h265_10bit" NVIDIA: "nvenc_h265_10bit" Intel: "qsv_h265_10bit"
|
# The best available encoder (HW-accelerated if possible) is auto-selected.
|
||||||
# primary = "vce_h265_10bit"
|
# codec = "hevc"
|
||||||
|
|
||||||
# CPU fallback encoder
|
|
||||||
# fallback = "x265_10bit"
|
|
||||||
|
|
||||||
# Encoder preset: "speed", "balanced", or "quality"
|
# Encoder preset: "speed", "balanced", or "quality"
|
||||||
# preset = "speed"
|
# preset = "speed"
|
||||||
|
|
||||||
# Constant quality (lower = better quality, bigger file)
|
# Constant quality / rate factor (lower = better quality, bigger file)
|
||||||
|
# Typical ranges: HEVC 18-28, H.264 18-24, AV1 25-35
|
||||||
# quality = 22
|
# quality = 22
|
||||||
|
|
||||||
# Seconds without progress before the disc is considered stuck (0 to disable)
|
# Seconds without progress before the disc is considered stuck (0 to disable)
|
||||||
@@ -86,8 +84,7 @@ def _platform_defaults() -> dict:
|
|||||||
return {
|
return {
|
||||||
"input_path": input_path,
|
"input_path": input_path,
|
||||||
"output_base": output_base,
|
"output_base": output_base,
|
||||||
"encoder_primary": "vce_h265_10bit",
|
"codec": "hevc",
|
||||||
"encoder_fallback": "x265_10bit",
|
|
||||||
"encoder_preset": "speed",
|
"encoder_preset": "speed",
|
||||||
"quality": 22,
|
"quality": 22,
|
||||||
"stall_timeout": 120,
|
"stall_timeout": 120,
|
||||||
@@ -108,8 +105,7 @@ def _load_config() -> dict:
|
|||||||
|
|
||||||
defaults["input_path"] = paths.get("input", defaults["input_path"])
|
defaults["input_path"] = paths.get("input", defaults["input_path"])
|
||||||
defaults["output_base"] = paths.get("output_base", defaults["output_base"])
|
defaults["output_base"] = paths.get("output_base", defaults["output_base"])
|
||||||
defaults["encoder_primary"] = encoder.get("primary", defaults["encoder_primary"])
|
defaults["codec"] = encoder.get("codec", defaults["codec"])
|
||||||
defaults["encoder_fallback"] = encoder.get("fallback", defaults["encoder_fallback"])
|
|
||||||
defaults["encoder_preset"] = encoder.get("preset", defaults["encoder_preset"])
|
defaults["encoder_preset"] = encoder.get("preset", defaults["encoder_preset"])
|
||||||
defaults["quality"] = encoder.get("quality", defaults["quality"])
|
defaults["quality"] = encoder.get("quality", defaults["quality"])
|
||||||
defaults["stall_timeout"] = encoder.get("stall_timeout", defaults["stall_timeout"])
|
defaults["stall_timeout"] = encoder.get("stall_timeout", defaults["stall_timeout"])
|
||||||
@@ -139,8 +135,7 @@ _cfg = _load_config()
|
|||||||
|
|
||||||
INPUT_PATH: str = _cfg["input_path"]
|
INPUT_PATH: str = _cfg["input_path"]
|
||||||
OUTPUT_BASE: str = _cfg["output_base"]
|
OUTPUT_BASE: str = _cfg["output_base"]
|
||||||
ENCODER_PRIMARY: str = _cfg["encoder_primary"]
|
CODEC: str = _cfg["codec"]
|
||||||
ENCODER_FALLBACK: str = _cfg["encoder_fallback"]
|
|
||||||
ENCODER_PRESET: str = _cfg["encoder_preset"]
|
ENCODER_PRESET: str = _cfg["encoder_preset"]
|
||||||
QUALITY: int = _cfg["quality"]
|
QUALITY: int = _cfg["quality"]
|
||||||
STALL_TIMEOUT: int = _cfg["stall_timeout"]
|
STALL_TIMEOUT: int = _cfg["stall_timeout"]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from rich.progress import (
|
|||||||
TimeRemainingColumn,
|
TimeRemainingColumn,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .config import ENCODER_PRESET, QUALITY, STALL_TIMEOUT, console
|
from .config import ENCODER_PRESET, STALL_TIMEOUT, console
|
||||||
|
|
||||||
|
|
||||||
def build_handbrake_cmd(
|
def build_handbrake_cmd(
|
||||||
@@ -23,6 +23,7 @@ def build_handbrake_cmd(
|
|||||||
audio_tracks: list[dict],
|
audio_tracks: list[dict],
|
||||||
subtitle_tracks: list[dict],
|
subtitle_tracks: list[dict],
|
||||||
encoder: str,
|
encoder: str,
|
||||||
|
quality: int = 22,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Build the full HandBrakeCLI command line."""
|
"""Build the full HandBrakeCLI command line."""
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -36,12 +37,11 @@ def build_handbrake_cmd(
|
|||||||
# Video
|
# Video
|
||||||
"--encoder", encoder,
|
"--encoder", encoder,
|
||||||
"--enable-hw-decoding",
|
"--enable-hw-decoding",
|
||||||
"--quality", str(QUALITY),
|
"--quality", str(quality),
|
||||||
"--rate", "30",
|
"--rate", "30",
|
||||||
"--pfr",
|
"--pfr",
|
||||||
"--color-range", "limited",
|
"--color-range", "limited",
|
||||||
"--encoder-preset", ENCODER_PRESET,
|
"--encoder-preset", ENCODER_PRESET,
|
||||||
"--encoder-profile", "main10",
|
|
||||||
"--encoder-level", "auto",
|
"--encoder-level", "auto",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,71 @@
|
|||||||
"""Disc scanning and encoder detection."""
|
"""Disc scanning and encoder detection."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .config import ENCODER_PRIMARY, ENCODER_FALLBACK, console
|
from rich.table import Table
|
||||||
|
from rich import box
|
||||||
|
|
||||||
|
from .config import console
|
||||||
|
|
||||||
|
|
||||||
|
# ── Encoder knowledge base ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Maps HandBrakeCLI encoder names → (codec_family, bit_depth, hw_vendor | None)
|
||||||
|
# hw_vendor is None for software encoders.
|
||||||
|
ENCODER_INFO: dict[str, tuple[str, int, str | None]] = {
|
||||||
|
# HEVC / H.265
|
||||||
|
"x265": ("hevc", 8, None),
|
||||||
|
"x265_10bit": ("hevc", 10, None),
|
||||||
|
"x265_12bit": ("hevc", 12, None),
|
||||||
|
"vce_h265": ("hevc", 8, "AMD"),
|
||||||
|
"vce_h265_10bit": ("hevc", 10, "AMD"),
|
||||||
|
"nvenc_h265": ("hevc", 8, "NVIDIA"),
|
||||||
|
"nvenc_h265_10bit": ("hevc", 10, "NVIDIA"),
|
||||||
|
"qsv_h265": ("hevc", 8, "Intel"),
|
||||||
|
"qsv_h265_10bit": ("hevc", 10, "Intel"),
|
||||||
|
"mf_h265": ("hevc", 8, "MediaFoundation"),
|
||||||
|
# H.264 / AVC
|
||||||
|
"x264": ("h264", 8, None),
|
||||||
|
"x264_10bit": ("h264", 10, None),
|
||||||
|
"vce_h264": ("h264", 8, "AMD"),
|
||||||
|
"nvenc_h264": ("h264", 8, "NVIDIA"),
|
||||||
|
"qsv_h264": ("h264", 8, "Intel"),
|
||||||
|
"mf_h264": ("h264", 8, "MediaFoundation"),
|
||||||
|
# AV1
|
||||||
|
"svt_av1": ("av1", 8, None),
|
||||||
|
"svt_av1_10bit": ("av1", 10, None),
|
||||||
|
"qsv_av1": ("av1", 8, "Intel"),
|
||||||
|
"qsv_av1_10bit": ("av1", 10, "Intel"),
|
||||||
|
"nvenc_av1": ("av1", 8, "NVIDIA"),
|
||||||
|
"nvenc_av1_10bit": ("av1", 10, "NVIDIA"),
|
||||||
|
"vce_av1": ("av1", 8, "AMD"),
|
||||||
|
"vce_av1_10bit": ("av1", 10, "AMD"),
|
||||||
|
# VP9
|
||||||
|
"vp9": ("vp9", 8, None),
|
||||||
|
"vp9_10bit": ("vp9", 10, None),
|
||||||
|
# MPEG-4 / MPEG-2 (legacy)
|
||||||
|
"mpeg4": ("mpeg4", 8, None),
|
||||||
|
"mpeg2": ("mpeg2", 8, None),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Scene-style codec tags for filenames
|
||||||
|
CODEC_SCENE_TAG = {
|
||||||
|
"hevc": "x265",
|
||||||
|
"h264": "x264",
|
||||||
|
"av1": "AV1",
|
||||||
|
"vp9": "VP9",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Friendly display names
|
||||||
|
CODEC_DISPLAY = {
|
||||||
|
"hevc": "HEVC (H.265)",
|
||||||
|
"h264": "H.264 (AVC)",
|
||||||
|
"av1": "AV1",
|
||||||
|
"vp9": "VP9",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str], capture: bool = True) -> subprocess.CompletedProcess:
|
def run(cmd: list[str], capture: bool = True) -> subprocess.CompletedProcess:
|
||||||
@@ -16,20 +77,126 @@ def run(cmd: list[str], capture: bool = True) -> subprocess.CompletedProcess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def detect_encoder() -> str:
|
def discover_encoders() -> dict[str, list[dict]]:
|
||||||
"""Return the best available H.265 10-bit encoder."""
|
"""
|
||||||
with console.status("[bold cyan]Detecting encoder…"):
|
Query HandBrakeCLI for available encoders and group by codec family.
|
||||||
|
|
||||||
|
Returns a dict like:
|
||||||
|
{
|
||||||
|
"hevc": [
|
||||||
|
{"name": "x265_10bit", "bits": 10, "hw": None},
|
||||||
|
{"name": "vce_h265_10bit", "bits": 10, "hw": "AMD"},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"h264": [...],
|
||||||
|
"av1": [...],
|
||||||
|
}
|
||||||
|
Only includes encoders that are actually available.
|
||||||
|
"""
|
||||||
|
with console.status("[bold cyan]Detecting available encoders…"):
|
||||||
result = run(["HandBrakeCLI", "--help"], capture=True)
|
result = run(["HandBrakeCLI", "--help"], capture=True)
|
||||||
combined = result.stdout + result.stderr
|
combined = result.stdout + result.stderr
|
||||||
if ENCODER_PRIMARY in combined:
|
|
||||||
console.print(f" [green]✓[/] Using hardware encoder: [bold]{ENCODER_PRIMARY}[/]")
|
|
||||||
return ENCODER_PRIMARY
|
|
||||||
console.print(
|
|
||||||
f" [yellow]⚠[/] {ENCODER_PRIMARY} not available, "
|
|
||||||
f"falling back to [bold]{ENCODER_FALLBACK}[/]"
|
|
||||||
)
|
|
||||||
return ENCODER_FALLBACK
|
|
||||||
|
|
||||||
|
available: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
for enc_name, (family, bits, hw) in ENCODER_INFO.items():
|
||||||
|
# Check if this encoder name appears in HandBrakeCLI's help output
|
||||||
|
if re.search(rf'\b{re.escape(enc_name)}\b', combined):
|
||||||
|
available.setdefault(family, []).append({
|
||||||
|
"name": enc_name,
|
||||||
|
"bits": bits,
|
||||||
|
"hw": hw,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort each family: HW first, then by bit depth descending
|
||||||
|
for family in available:
|
||||||
|
available[family].sort(key=lambda e: (e["hw"] is None, -e["bits"]))
|
||||||
|
|
||||||
|
return available
|
||||||
|
|
||||||
|
|
||||||
|
def print_encoder_table(available: dict[str, list[dict]]) -> None:
|
||||||
|
"""Display available encoders grouped by codec family in a rich table."""
|
||||||
|
table = Table(
|
||||||
|
title="Available Encoders",
|
||||||
|
box=box.ROUNDED,
|
||||||
|
title_style="bold cyan",
|
||||||
|
header_style="bold",
|
||||||
|
show_lines=True,
|
||||||
|
padding=(0, 1),
|
||||||
|
)
|
||||||
|
table.add_column("Codec", style="bold white", width=16)
|
||||||
|
table.add_column("Encoders", style="dim")
|
||||||
|
|
||||||
|
# Display order
|
||||||
|
for family in ("hevc", "h264", "av1", "vp9"):
|
||||||
|
encoders = available.get(family, [])
|
||||||
|
if not encoders:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for e in encoders:
|
||||||
|
if e["hw"]:
|
||||||
|
parts.append(f"[green]{e['name']}[/] [bold green]⚡{e['hw']}[/]")
|
||||||
|
else:
|
||||||
|
parts.append(f"[dim]{e['name']}[/] [dim]CPU[/]")
|
||||||
|
|
||||||
|
display = CODEC_DISPLAY.get(family, family.upper())
|
||||||
|
table.add_row(display, " ".join(parts))
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print(table)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
def select_encoder(
|
||||||
|
available: dict[str, list[dict]],
|
||||||
|
codec: str = "hevc",
|
||||||
|
prefer_10bit: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Select the best encoder for a given codec family.
|
||||||
|
|
||||||
|
Priority: HW 10-bit → HW 8-bit → SW 10-bit → SW 8-bit
|
||||||
|
Falls back across families if the requested codec has no encoders.
|
||||||
|
"""
|
||||||
|
encoders = available.get(codec, [])
|
||||||
|
|
||||||
|
if not encoders:
|
||||||
|
console.print(
|
||||||
|
f" [yellow]⚠[/] No {CODEC_DISPLAY.get(codec, codec)} encoders available."
|
||||||
|
)
|
||||||
|
# Fall back to HEVC, then H.264
|
||||||
|
for fallback in ("hevc", "h264", "av1"):
|
||||||
|
if fallback != codec and fallback in available:
|
||||||
|
console.print(f" [dim]Falling back to {CODEC_DISPLAY.get(fallback, fallback)}[/]")
|
||||||
|
encoders = available[fallback]
|
||||||
|
codec = fallback
|
||||||
|
break
|
||||||
|
|
||||||
|
if not encoders:
|
||||||
|
console.print("[bold red]ERROR:[/] No video encoders available.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# The list is already sorted HW-first, highest bit depth first
|
||||||
|
# If we prefer 10-bit, just take the first one
|
||||||
|
if prefer_10bit:
|
||||||
|
selected = encoders[0]
|
||||||
|
else:
|
||||||
|
# Prefer 8-bit
|
||||||
|
eight_bit = [e for e in encoders if e["bits"] == 8]
|
||||||
|
selected = eight_bit[0] if eight_bit else encoders[0]
|
||||||
|
|
||||||
|
enc = selected
|
||||||
|
hw_tag = f" [bold green]⚡{enc['hw']}[/]" if enc["hw"] else " [dim]CPU[/]"
|
||||||
|
console.print(
|
||||||
|
f" [green]✓[/] Encoder: [bold]{enc['name']}[/]{hw_tag} "
|
||||||
|
f"[dim]({enc['bits']}-bit {CODEC_DISPLAY.get(codec, codec)})[/]"
|
||||||
|
)
|
||||||
|
return enc["name"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Disc scanning ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def scan_disc(input_path: str) -> dict:
|
def scan_disc(input_path: str) -> dict:
|
||||||
"""Scan the disc and return the parsed JSON structure."""
|
"""Scan the disc and return the parsed JSON structure."""
|
||||||
@@ -42,30 +209,19 @@ def scan_disc(input_path: str) -> dict:
|
|||||||
"--scan",
|
"--scan",
|
||||||
"--previews", "1:0",
|
"--previews", "1:0",
|
||||||
])
|
])
|
||||||
# HandBrakeCLI mixes stderr log lines with JSON on stdout.
|
|
||||||
# JSON blocks are labeled, e.g.:
|
|
||||||
# Version: { ... }
|
|
||||||
# Progress: { ... }
|
|
||||||
# JSON Title Set: { "MainFeature": ..., "TitleList": [...] }
|
|
||||||
# We want the "JSON Title Set" block. Also, "HandBrake has exited."
|
|
||||||
# can appear mid-stream and must be stripped.
|
|
||||||
combined = (result.stdout or "") + (result.stderr or "")
|
combined = (result.stdout or "") + (result.stderr or "")
|
||||||
|
|
||||||
# Strip injected noise lines
|
|
||||||
cleaned_lines = [
|
cleaned_lines = [
|
||||||
line for line in combined.splitlines()
|
line for line in combined.splitlines()
|
||||||
if "HandBrake has exited" not in line
|
if "HandBrake has exited" not in line
|
||||||
]
|
]
|
||||||
|
|
||||||
# Find the "JSON Title Set:" block and extract the JSON from it
|
|
||||||
capture = False
|
capture = False
|
||||||
depth = 0
|
depth = 0
|
||||||
buf: list[str] = []
|
buf: list[str] = []
|
||||||
for line in cleaned_lines:
|
for line in cleaned_lines:
|
||||||
if not capture:
|
if not capture:
|
||||||
# Look for the label line
|
|
||||||
if "JSON Title Set:" in line:
|
if "JSON Title Set:" in line:
|
||||||
# The JSON starts after the label on the same line
|
|
||||||
json_start = line.index("{")
|
json_start = line.index("{")
|
||||||
buf.append(line[json_start:])
|
buf.append(line[json_start:])
|
||||||
depth += line[json_start:].count("{") - line[json_start:].count("}")
|
depth += line[json_start:].count("{") - line[json_start:].count("}")
|
||||||
@@ -73,7 +229,6 @@ def scan_disc(input_path: str) -> dict:
|
|||||||
if depth <= 0:
|
if depth <= 0:
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
# Inside JSON block
|
|
||||||
buf.append(line)
|
buf.append(line)
|
||||||
depth += line.count("{") - line.count("}")
|
depth += line.count("{") - line.count("}")
|
||||||
if depth <= 0:
|
if depth <= 0:
|
||||||
@@ -98,7 +253,6 @@ def select_title(scan: dict) -> dict:
|
|||||||
console.print("[bold red]ERROR:[/] No titles found on disc.")
|
console.print("[bold red]ERROR:[/] No titles found on disc.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Prefer the one flagged MainFeature, else longest duration
|
|
||||||
main = [t for t in titles if t.get("MainFeature")]
|
main = [t for t in titles if t.get("MainFeature")]
|
||||||
if main:
|
if main:
|
||||||
title = main[0]
|
title = main[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user