From 4ae6951c9cebe9259e3ee286078d53a5fa2f0080 Mon Sep 17 00:00:00 2001 From: Tomas Kracmar Date: Wed, 24 Sep 2025 12:19:29 +0200 Subject: [PATCH] Adding GPU transcoding possibility --- app/worker.py | 112 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 75 insertions(+), 37 deletions(-) diff --git a/app/worker.py b/app/worker.py index 6775909..96e7ca1 100644 --- a/app/worker.py +++ b/app/worker.py @@ -544,7 +544,7 @@ def reuse_repo_transcript(media_path: Path, repo_json: Path) -> Path | None: # copy or synthesize TXT if src_txt.exists(): - shutil.copy2(src_txt, new_base.with_suffix(".txt")) + _safe_copy(src_txt, new_base.with_suffix(".txt")) else: # fallback: concatenate segments txt = " ".join(s.get("text", "") for s in data.get("segments", [])) @@ -552,7 +552,7 @@ def reuse_repo_transcript(media_path: Path, repo_json: Path) -> Path | None: # copy SRT/VTT if present; otherwise synthesize SRT from segments if src_srt.exists(): - shutil.copy2(src_srt, new_base.with_suffix(".srt")) + _safe_copy(src_srt, new_base.with_suffix(".srt")) else: # synthesize SRT def fmt_ts(t): @@ -562,7 +562,7 @@ def reuse_repo_transcript(media_path: Path, repo_json: Path) -> Path | None: for i, s in enumerate(data.get("segments", []), 1): srt.write(f"{i}\n{fmt_ts(s.get('start',0.0))} --> {fmt_ts(s.get('end',0.0))}\n{s.get('text','').strip()}\n\n") if src_vtt.exists(): - shutil.copy2(src_vtt, new_base.with_suffix(".vtt")) + _safe_copy(src_vtt, new_base.with_suffix(".vtt")) else: # synthesize VTT from segments def fmt_ts_vtt(t): @@ -642,7 +642,7 @@ def ensure_sidecar_next_to_media(sidecar: Path, media_path: Path, lang: str = "e return if sidecar.suffix.lower() == ".srt": dst = media_path.with_suffix(f".{lang}.srt") - shutil.copy2(sidecar, dst) + _safe_copy(sidecar, dst) elif sidecar.suffix.lower() == ".vtt": tmp_srt = sidecar.with_suffix(".srt") subprocess.run(["ffmpeg", "-nostdin", "-y", "-threads", str(FFMPEG_THREADS), "-i", str(sidecar), str(tmp_srt)], check=True) @@ -708,7 +708,7 @@ def save_episode_artwork(image_url: str | None, media_path: Path, show_title: st try: show_poster = media_path.parent / "poster.jpg" if not show_poster.exists(): - shutil.copy2(episode_jpg, show_poster) + _safe_copy(episode_jpg, show_poster) except Exception: pass @@ -1187,7 +1187,7 @@ def transcribe(media_path: Path): lang_code = (info.language or (WHISPER_LANGUAGE if WHISPER_LANGUAGE.lower() != 'auto' else 'en')).lower() srt_src = base.with_suffix(".srt") srt_dst = media_path.with_suffix(f".{lang_code}.srt") - shutil.copy2(srt_src, srt_dst) + _safe_copy(srt_src, srt_dst) except Exception as e: print(f"[post] could not copy srt -> {srt_dst}: {e}", flush=True) @@ -1500,6 +1500,17 @@ def _unique_backup_path(path: Path) -> Path: counter += 1 +def _safe_copy(src: Path, dst: Path) -> None: + try: + if src.resolve(strict=False) == dst.resolve(strict=False): + return + except Exception: + if os.path.abspath(src) == os.path.abspath(dst): + return + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + def _is_sidecar_name(name: str, base_stem: str, base_name: str) -> bool: exact_suffixes = [".info.json", ".nfo", ".jpg", ".jpeg", ".png", ".webp", ".prov.json"] for suf in exact_suffixes: @@ -1585,41 +1596,68 @@ def _normalize_video_file(path: Path, info: dict[str, str]) -> Path: if tmp_path.exists(): tmp_path.unlink() - cmd = [ - "ffmpeg", "-nostdin", "-y", - "-i", str(path), - "-map", "0", - "-c:v", encoder, - ] - if VIDEO_NORMALIZE_PRESET: - cmd.extend(["-preset", VIDEO_NORMALIZE_PRESET]) - if VIDEO_NORMALIZE_TUNE: - cmd.extend(["-tune", VIDEO_NORMALIZE_TUNE]) - if VIDEO_NORMALIZE_CRF: - cmd.extend(["-crf", VIDEO_NORMALIZE_CRF]) + def build_cmd(v_encoder: str) -> list[str]: + cmd = [ + "ffmpeg", "-nostdin", "-y", + "-i", str(path), + "-map", "0", + "-c:v", v_encoder, + ] + if VIDEO_NORMALIZE_PRESET: + cmd.extend(["-preset", VIDEO_NORMALIZE_PRESET]) + if VIDEO_NORMALIZE_TUNE: + cmd.extend(["-tune", VIDEO_NORMALIZE_TUNE]) + if VIDEO_NORMALIZE_CRF: + cmd.extend(["-crf", VIDEO_NORMALIZE_CRF]) - if info.get("audio"): - if VIDEO_NORMALIZE_AUDIO_CODEC == "copy": - cmd.extend(["-c:a", "copy"]) + if info.get("audio"): + if VIDEO_NORMALIZE_AUDIO_CODEC == "copy": + cmd.extend(["-c:a", "copy"]) + else: + cmd.extend(["-c:a", _resolve_audio_encoder(VIDEO_NORMALIZE_AUDIO_CODEC)]) + if VIDEO_NORMALIZE_AUDIO_BITRATE: + cmd.extend(["-b:a", VIDEO_NORMALIZE_AUDIO_BITRATE]) else: - cmd.extend(["-c:a", _resolve_audio_encoder(VIDEO_NORMALIZE_AUDIO_CODEC)]) - if VIDEO_NORMALIZE_AUDIO_BITRATE: - cmd.extend(["-b:a", VIDEO_NORMALIZE_AUDIO_BITRATE]) - else: - cmd.append("-an") + cmd.append("-an") - cmd.extend(["-c:s", "copy", str(tmp_path)]) + cmd.extend(["-c:s", "copy", str(tmp_path)]) + return cmd - print(f"[normalize] video -> {final_path.name} codec={VIDEO_NORMALIZE_CODEC}", flush=True) - try: - subprocess.check_call(cmd) - except subprocess.CalledProcessError as e: - if tmp_path.exists(): - tmp_path.unlink() - raise RuntimeError(f"ffmpeg video normalize failed: {e}") + def fallback_encoder() -> str: + base = VIDEO_NORMALIZE_CODEC.lower() + if base.startswith("hevc") or base.startswith("h265"): + return "libx265" + if base.startswith("h264") or base.startswith("avc"): + return "libx264" + if base.startswith("av1"): + return "libaom-av1" + return "libx265" - rename_media_sidecars(path, final_path, skip={tmp_path}) - return _finalize_normalized_output(path, final_path, tmp_path) + attempted = [] + encoders_to_try = [encoder] + lower_encoder = encoder.lower() + if any(token in lower_encoder for token in ("nvenc", "qsv", "cuda", "amf")): + cpu_encoder = fallback_encoder() + if cpu_encoder not in encoders_to_try: + encoders_to_try.append(cpu_encoder) + + for idx, enc in enumerate(encoders_to_try): + try: + print(f"[normalize] video -> {final_path.name} codec={enc}", flush=True) + subprocess.check_call(build_cmd(enc)) + rename_media_sidecars(path, final_path, skip={tmp_path}) + return _finalize_normalized_output(path, final_path, tmp_path) + except subprocess.CalledProcessError as e: + attempted.append((enc, e)) + if tmp_path.exists(): + tmp_path.unlink() + if idx == len(encoders_to_try) - 1: + details = ", ".join(f"{enc}: {err}" for enc, err in attempted) + raise RuntimeError(f"ffmpeg video normalize failed ({details})") + else: + print(f"[normalize] encoder {enc} failed ({e}); retrying with CPU fallback", flush=True) + + return path def _normalize_audio_file(path: Path, info: dict[str, str]) -> Path: @@ -2054,7 +2092,7 @@ def refresh_media(path_str: str): for s in p.parent.glob(f"{p.stem}*.srt"): # If it's already lang-suffixed, keep; also copy to .en.srt when only plain .srt exists if s.name == f"{p.stem}.srt": - shutil.copy2(s, p.with_suffix(".en.srt")) + _safe_copy(s, p.with_suffix(".en.srt")) except Exception: pass