diff --git a/OneKeyStart.bat b/OneKeyStart.bat index f58aff66..68f7a1b4 100644 --- a/OneKeyStart.bat +++ b/OneKeyStart.bat @@ -1,11 +1,93 @@ @echo off -chcp 65001 >nul 2>&1 -call conda activate videolingo 2>nul -set PYTHONWARNINGS=ignore -python "%~dp0launch.py" -if %errorlevel% neq 0 ( - echo. - echo Pre-flight checks or Streamlit failed. See logs\ for details. - echo. +setlocal EnableExtensions EnableDelayedExpansion +cd /D "%~dp0" + +for /F "tokens=1,2 delims=#" %%A in ('"prompt #$H#$E# & echo on & for %%B in (1) do rem"') do set "ESC=%%B" +set "C_RESET=%ESC%[0m" +set "C_GREEN=%ESC%[32m" +set "C_YELLOW=%ESC%[33m" +set "C_RED=%ESC%[31m" +set "C_CYAN=%ESC%[36m" +set "C_BOLD=%ESC%[1m" + +if not exist "logs" mkdir "logs" +for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value') do set dt=%%I +set "LOGFILE=logs\videolingo_%dt:~0,8%_%dt:~8,6%.log" +set "CHECK_ONLY=" +if /I "%~1"=="--check-only" set "CHECK_ONLY=1" + +echo [%date% %time%] VideoLingo starting... > "%LOGFILE%" +echo %C_CYAN%Log file:%C_RESET% %LOGFILE% + +set "VENV_LABEL=" +set "VENV_PY=" + +set "SHARED_VENV=%USERPROFILE%\.venvs\videolingo" +if exist "%SHARED_VENV%\Scripts\python.exe" ( + set "VENV_LABEL=shared venv" + set "VENV_PY=%SHARED_VENV%\Scripts\python.exe" + goto venv_found ) + +if exist ".venv\Scripts\python.exe" ( + set "VENV_LABEL=project .venv" + set "VENV_PY=.venv\Scripts\python.exe" + goto venv_found +) + +where conda >nul 2>nul +if %errorlevel%==0 ( + echo %C_YELLOW%No uv venv found, falling back to Conda env "videolingo"...%C_RESET% + call conda activate videolingo + if errorlevel 1 ( + echo %C_RED%ERROR: Failed to activate Conda env "videolingo".%C_RESET% + goto install_failed + ) + if /I not "!CONDA_DEFAULT_ENV!"=="videolingo" ( + echo %C_RED%ERROR: Conda env "videolingo" is not active. Current env: !CONDA_DEFAULT_ENV!%C_RESET% + goto install_failed + ) + python installer.py --check --quiet + if errorlevel 1 ( + echo %C_YELLOW%Conda env is incomplete or outdated. Repairing...%C_RESET% + python installer.py --yes + if errorlevel 1 goto install_failed + ) + if defined CHECK_ONLY ( + echo %C_GREEN%Environment check passed. --check-only set, not starting Streamlit.%C_RESET% + goto end + ) + echo %C_GREEN%Starting VideoLingo with Conda...%C_RESET% + python -m streamlit run st.py 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOGFILE%' -Append" + goto end +) + +echo %C_RED%ERROR: No usable VideoLingo environment found.%C_RESET% +echo Run one of these first: +echo python setup_env.py --shared +echo python setup_env.py +goto end + +:venv_found +echo %C_GREEN%Detected %VENV_LABEL%:%C_RESET% %VENV_PY% +"%VENV_PY%" installer.py --check --quiet +if errorlevel 1 ( + echo %C_YELLOW%Environment is incomplete or outdated. Repairing with installer.py...%C_RESET% + "%VENV_PY%" installer.py --yes + if errorlevel 1 goto install_failed +) + +if defined CHECK_ONLY ( + echo %C_GREEN%Environment check passed. --check-only set, not starting Streamlit.%C_RESET% + goto end +) + +echo %C_GREEN%Starting VideoLingo with %VENV_LABEL%...%C_RESET% +"%VENV_PY%" -m streamlit run st.py 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%LOGFILE%' -Append" +goto end + +:install_failed +echo %C_RED%Install/repair failed. Check the messages above and the log file.%C_RESET% + +:end pause diff --git a/README.md b/README.md index 3d106eb2..da270ec0 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ python setup_env.py .venv/bin/streamlit run st.py # macOS / Linux ``` -Or double-click `OneKeyStart_uv.bat` on Windows. +Or double-click `OneKeyStart.bat` on Windows. ### Option B: Using Conda diff --git a/config.yaml b/config.yaml index fe5153f6..141f2e5f 100644 --- a/config.yaml +++ b/config.yaml @@ -1,7 +1,7 @@ # * Settings marked with * are advanced settings that won't appear in the Streamlit page and can only be modified manually in config.py # recommend to set in streamlit page # ------------------- -# version: "3.0.2" +# version: "3.0.3" # author: "Huanshere" # ------------------- diff --git a/core/asr_backend/whisperX_local.py b/core/asr_backend/whisperX_local.py index da96c7b8..2caab063 100644 --- a/core/asr_backend/whisperX_local.py +++ b/core/asr_backend/whisperX_local.py @@ -4,6 +4,7 @@ import subprocess import torch import functools +from pathlib import Path warnings.filterwarnings("ignore") @@ -34,6 +35,22 @@ def _patched_torch_load(*args, **kwargs): from core.utils import * MODEL_DIR = load_key("model_dir") + +def _hf_cache_dir_for_repo(cache_root, repo_id): + return Path(cache_root) / f"models--{repo_id.replace('/', '--')}" + + +def _has_complete_hf_snapshot(cache_root, repo_id): + repo_dir = _hf_cache_dir_for_repo(cache_root, repo_id) + snapshots = repo_dir / "snapshots" + if not snapshots.exists(): + return False + required_files = {"config.json", "model.bin", "tokenizer.json"} + for snapshot in snapshots.iterdir(): + if snapshot.is_dir() and all((snapshot / name).exists() for name in required_files): + return True + return False + @except_handler("failed to check hf mirror", default_return=None) def check_hf_mirror(): mirrors = {'Official': 'huggingface.co', 'Mirror': 'hf-mirror.com'} @@ -76,6 +93,7 @@ def transcribe_audio(raw_audio_file, vocal_audio_file, start, end): rprint(f"[cyan]📦 Batch size:[/cyan] {batch_size}, [cyan]⚙️ Compute type:[/cyan] {compute_type}") rprint(f"[green]▶️ Starting WhisperX for segment {start:.2f}s to {end:.2f}s...[/green]") + download_root = MODEL_DIR if WHISPER_LANGUAGE == 'zh': model_name = "Huan69/Belle-whisper-large-v3-zh-punct-fasterwhisper" local_model = os.path.join(MODEL_DIR, "Belle-whisper-large-v3-zh-punct-fasterwhisper") @@ -86,14 +104,34 @@ def transcribe_audio(raw_audio_file, vocal_audio_file, start, end): if os.path.exists(local_model): rprint(f"[green]📥 Loading local WHISPER model:[/green] {local_model} ...") model_name = local_model + download_root = None else: rprint(f"[green]📥 Using WHISPER model from HuggingFace:[/green] {model_name} ...") + # If the project-local cache is missing or only partially downloaded, + # let HuggingFace use the default global cache. This avoids getting + # stuck on a half-created ./_model_cache after a network interruption. + repo_id = model_name if "/" in model_name else f"Systran/faster-whisper-{model_name}" + if not _has_complete_hf_snapshot(MODEL_DIR, repo_id): + rprint( + "[yellow]⚠️ Project model cache is incomplete; " + "falling back to the global HuggingFace cache.[/yellow]" + ) + download_root = None vad_options = {"vad_onset": 0.500,"vad_offset": 0.363} asr_options = {"temperatures": [0],"initial_prompt": "",} whisper_language = None if 'auto' in WHISPER_LANGUAGE else WHISPER_LANGUAGE rprint("[bold yellow] You can ignore warning of `Model was trained with torch 1.10.0+cu102, yours is 2.0.0+cu118...`[/bold yellow]") - model = whisperx.load_model(model_name, device, compute_type=compute_type, language=whisper_language, vad_options=vad_options, asr_options=asr_options, download_root=MODEL_DIR) + load_kwargs = dict( + device=device, + compute_type=compute_type, + language=whisper_language, + vad_options=vad_options, + asr_options=asr_options, + ) + if download_root: + load_kwargs["download_root"] = download_root + model = whisperx.load_model(model_name, **load_kwargs) def load_audio_segment(audio_file, start, end): # Use whisperx's ffmpeg-based loader instead of librosa.load() which @@ -147,4 +185,4 @@ def load_audio_segment(audio_file, start, end): word['start'] += start if 'end' in word: word['end'] += start - return result \ No newline at end of file + return result diff --git a/docs/pages/docs/start.en-US.md b/docs/pages/docs/start.en-US.md index 19c21013..205aea98 100644 --- a/docs/pages/docs/start.en-US.md +++ b/docs/pages/docs/start.en-US.md @@ -162,7 +162,7 @@ VideoLingo supports Windows, macOS and Linux systems, and can run on CPU or GPU. .venv\Scripts\streamlit run st.py # Windows .venv/bin/streamlit run st.py # macOS / Linux ``` - Or double-click `OneKeyStart_uv.bat` on Windows. + Or double-click `OneKeyStart.bat` on Windows. 4. Set key in sidebar of popup webpage and start using~ diff --git a/docs/pages/docs/start.zh-CN.md b/docs/pages/docs/start.zh-CN.md index 2913c516..f9f7212e 100644 --- a/docs/pages/docs/start.zh-CN.md +++ b/docs/pages/docs/start.zh-CN.md @@ -162,7 +162,7 @@ VideoLingo 支持 Windows、macOS 和 Linux 系统,可使用 CPU 或 GPU 运 .venv\Scripts\streamlit run st.py # Windows .venv/bin/streamlit run st.py # macOS / Linux ``` - 或在 Windows 上双击 `OneKeyStart_uv.bat`。 + 或在 Windows 上双击 `OneKeyStart.bat`。 4. 在弹出网页的侧边栏中设置 key,开始使用~ @@ -242,4 +242,4 @@ VideoLingo 支持 Windows、macOS 和 Linux 系统,可使用 CPU 或 GPU 运 python -m pip install xx-core-web-md --no-user --force-reinstall --no-deps ``` -9. **pip 安装后 torchaudio 版本变成 1.x 或 2.1.x**: demucs 的 `torchaudio<2.2` 约束导致降级。**解决方案:** 不要手动 `pip install demucs`,必须用 `--no-deps` 安装。`install.py` 已正确处理。 \ No newline at end of file +9. **pip 安装后 torchaudio 版本变成 1.x 或 2.1.x**: demucs 的 `torchaudio<2.2` 约束导致降级。**解决方案:** 不要手动 `pip install demucs`,必须用 `--no-deps` 安装。`install.py` 已正确处理。 diff --git a/install.py b/install.py index 9e38f035..c0e87953 100644 --- a/install.py +++ b/install.py @@ -1,263 +1,19 @@ -import os, sys -import platform -import subprocess -sys.path.append(os.path.dirname(os.path.abspath(__file__))) +"""Compatibility wrapper for the stage-based installer. -ascii_logo = """ -__ ___ _ _ _ -\ \ / (_) __| | ___ ___ | | (_)_ __ __ _ ___ - \ \ / /| |/ _` |/ _ \/ _ \| | | | '_ \ / _` |/ _ \ - \ V / | | (_| | __/ (_) | |___| | | | | (_| | (_) | - \_/ |_|\__,_|\___|\___/|_____|_|_| |_|\__, |\___/ - |___/ +Historically users ran ``python install.py`` and the app launched at the end. +Keep that behavior here while moving the real installation logic to +``installer.py`` so setup_env.py and launchers can reuse it safely. """ -def install_package(*packages): - subprocess.check_call([sys.executable, "-m", "pip", "install", *packages]) +from __future__ import annotations -def check_nvidia_gpu(): - install_package("nvidia-ml-py") - import pynvml - from translations.translations import translate as t - initialized = False - try: - pynvml.nvmlInit() - initialized = True - device_count = pynvml.nvmlDeviceGetCount() - if device_count > 0: - print(t("Detected NVIDIA GPU(s)")) - for i in range(device_count): - handle = pynvml.nvmlDeviceGetHandleByIndex(i) - name = pynvml.nvmlDeviceGetName(handle) - print(f"GPU {i}: {name}") - return True - else: - print(t("No NVIDIA GPU detected")) - return False - except pynvml.NVMLError: - print(t("No NVIDIA GPU detected or NVIDIA drivers not properly installed")) - return False - finally: - if initialized: - pynvml.nvmlShutdown() +import sys -def check_ffmpeg(): - from rich.console import Console - from rich.panel import Panel - from translations.translations import translate as t - console = Console() +from installer import main - try: - # Check if ffmpeg is installed - subprocess.run(['ffmpeg', '-version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) - console.print(Panel(t("✅ FFmpeg is already installed"), style="green")) - except (subprocess.CalledProcessError, FileNotFoundError): - system = platform.system() - install_cmd = "" - - if system == "Windows": - install_cmd = "choco install ffmpeg" - extra_note = t("Install Chocolatey first (https://chocolatey.org/)") - elif system == "Darwin": - install_cmd = "brew install ffmpeg" - extra_note = t("Install Homebrew first (https://brew.sh/)") - elif system == "Linux": - install_cmd = "sudo apt install ffmpeg # Ubuntu/Debian\nsudo yum install ffmpeg # CentOS/RHEL" - extra_note = t("Use your distribution's package manager") - - console.print(Panel.fit( - t("❌ FFmpeg not found\n\n") + - f"{t('🛠️ Install using:')}\n[bold cyan]{install_cmd}[/bold cyan]\n\n" + - f"{t('💡 Note:')}\n{extra_note}\n\n" + - f"{t('🔄 After installing FFmpeg, please run this installer again:')}\n[bold cyan]python install.py[/bold cyan]", - style="red" - )) - raise SystemExit(t("FFmpeg is required. Please install it and run the installer again.")) - - # Warn if ffmpeg lacks libmp3lame (common with conda-forge builds) - try: - result = subprocess.run(['ffmpeg', '-encoders'], capture_output=True, text=True, timeout=10) - if 'libmp3lame' not in result.stdout: - console.print(Panel.fit( - "⚠️ Your ffmpeg does not include [bold]libmp3lame[/bold] (MP3 encoder).\n" - "This is common with conda-forge ffmpeg builds.\n\n" - "VideoLingo will fall back to WAV encoding automatically, but for\n" - "smaller intermediate files, consider installing a full ffmpeg:\n\n" - "[bold cyan]" + ( - "winget install Gyan.FFmpeg" if platform.system() == "Windows" - else "brew install ffmpeg" if platform.system() == "Darwin" - else "sudo apt install ffmpeg" - ) + "[/bold cyan]", - style="yellow" - )) - except Exception: - pass - -def _detect_cuda_version_from_smi(): - """Detect CUDA version from nvidia-smi output (driver's CUDA capability).""" - import re - try: - result = subprocess.run( - ["nvidia-smi"], capture_output=True, text=True, timeout=10 - ) - m = re.search(r"CUDA Version:\s*(\d+)\.(\d+)", result.stdout) - if m: - return (int(m.group(1)), int(m.group(2))) - except Exception: - pass - return None - - -def _detect_cuda_index(): - """Detect the CUDA version and return the best PyTorch wheel index URL. - Falls back to cu126 when detection fails. - - For RTX 50 series (Blackwell architecture, compute capability 10.0+), - we need PyTorch wheels compiled with CUDA 12.8+ that include sm_100 kernels. - - We prefer nvidia-smi (driver CUDA version) over nvcc (toolkit version) because: - - Driver version determines what CUDA features the GPU can run at runtime - - Toolkit version is for compilation, not runtime compatibility - - Blackwell GPUs need cu129+ wheels even if user has older CUDA toolkit installed - """ - cuda_version = _detect_cuda_version_from_smi() - - # Map CUDA major.minor to PyTorch wheel index. - # For CUDA 13.x (RTX 50 series / Blackwell), use cu129 which includes sm_100 kernels. - INDEX = "https://download.pytorch.org/whl" - CU_TAGS = [ - ((13, 0), "cu129"), # CUDA 13.x (Blackwell / RTX 50 series) - ((12, 9), "cu129"), # CUDA 12.9+ - ((12, 8), "cu128"), # CUDA 12.8+ - ((12, 6), "cu126"), # CUDA 12.6+ - ] - - if cuda_version: - for min_ver, tag in CU_TAGS: - if cuda_version >= min_ver: - return f"{INDEX}/{tag}" - - # Default: cu126 is the broadest CUDA 12 index for PyTorch 2.8 - return f"{INDEX}/cu126" - -def main(): - install_package("requests", "rich", "ruamel.yaml", "InquirerPy") - from rich.console import Console - from rich.panel import Panel - from rich.box import DOUBLE - from InquirerPy import inquirer - from translations.translations import translate as t - from translations.translations import DISPLAY_LANGUAGES - from core.utils.config_utils import load_key, update_key - from core.utils.decorator import except_handler - - console = Console() - - width = max(len(line) for line in ascii_logo.splitlines()) + 4 - welcome_panel = Panel( - ascii_logo, - width=width, - box=DOUBLE, - title="[bold green]🌏[/bold green]", - border_style="bright_blue" - ) - console.print(welcome_panel) - # Language selection - current_language = load_key("display_language") - # Find the display name for current language code - current_display = next((k for k, v in DISPLAY_LANGUAGES.items() if v == current_language), "🇬🇧 English") - selected_language = DISPLAY_LANGUAGES[inquirer.select( - message="Select language / 选择语言 / 選擇語言 / 言語を選択 / Seleccionar idioma / Sélectionner la langue / Выберите язык:", - choices=list(DISPLAY_LANGUAGES.keys()), - default=current_display - ).execute()] - update_key("display_language", selected_language) - - console.print(Panel.fit(t("🚀 Starting Installation"), style="bold magenta")) - - # Configure mirrors - # add a check to ask user if they want to configure mirrors - if inquirer.confirm( - message=t("Do you need to auto-configure PyPI mirrors? (Recommended if you have difficulty accessing pypi.org)"), - default=True - ).execute(): - from core.utils.pypi_autochoose import main as choose_mirror - choose_mirror() - - # Detect system and GPU - has_gpu = platform.system() != 'Darwin' and check_nvidia_gpu() - - @except_handler("Failed to install PyTorch", retry=1, delay=5) - def install_pytorch(): - if has_gpu: - console.print(Panel(t("🎮 NVIDIA GPU detected, installing CUDA version of PyTorch..."), style="cyan")) - cuda_index = _detect_cuda_index() - console.print(f"[cyan]📦 Using PyTorch index:[/cyan] {cuda_index}") - subprocess.check_call([sys.executable, "-m", "pip", "install", "torch==2.8.0", "torchaudio==2.8.0", "--index-url", cuda_index]) - else: - system_name = "🍎 MacOS" if platform.system() == 'Darwin' else "💻 No NVIDIA GPU" - console.print(Panel(t(f"{system_name} detected, installing CPU version of PyTorch... Note: it might be slow during whisperX transcription."), style="cyan")) - subprocess.check_call([sys.executable, "-m", "pip", "install", "torch==2.8.0", "torchaudio==2.8.0"]) - - @except_handler("Failed to install project", retry=1, delay=5) - def install_requirements(): - # Install demucs separately with --no-deps to avoid its outdated - # torchaudio<2.2 constraint conflicting with whisperx's torchaudio>=2.5.1. - # demucs works fine with torchaudio 2.6.0 at runtime. - console.print(Panel(t("Installing demucs (--no-deps to avoid torchaudio conflict)..."), style="cyan")) - subprocess.check_call([sys.executable, "-m", "pip", "install", "--no-deps", "demucs[dev]@git+https://github.com/adefossez/demucs"]) - # demucs --no-deps skips its own dependencies; install the ones it - # actually needs at runtime that aren't already pulled in elsewhere. - console.print(Panel(t("Installing demucs runtime dependencies..."), style="cyan")) - subprocess.check_call([sys.executable, "-m", "pip", "install", "dora-search", "openunmix", "lameenc"]) - - console.print(Panel(t("Installing project in editable mode using `pip install -e .`"), style="cyan")) - subprocess.check_call([sys.executable, "-m", "pip", "install", "-e", "."], env={**os.environ, "PYTHONIOENCODING": "utf-8"}) - - @except_handler("Failed to install Noto fonts") - def install_noto_font(): - # Detect Linux distribution type - if os.path.exists('/etc/debian_version'): - # Debian/Ubuntu systems - cmd = ['sudo', 'apt-get', 'install', '-y', 'fonts-noto'] - pkg_manager = "apt-get" - elif os.path.exists('/etc/redhat-release'): - # RHEL/CentOS/Fedora systems - cmd = ['sudo', 'yum', 'install', '-y', 'google-noto*'] - pkg_manager = "yum" - else: - console.print("Warning: Unrecognized Linux distribution, please install Noto fonts manually", style="yellow") - return - - subprocess.run(cmd, check=True) - console.print(f"✅ Successfully installed Noto fonts using {pkg_manager}", style="green") - - if platform.system() == 'Linux': - install_noto_font() - - install_pytorch() - install_requirements() - check_ffmpeg() - - # First panel with installation complete and startup command - panel1_text = ( - t("Installation completed") + "\n\n" + - t("Now I will run this command to start the application:") + "\n" + - "[bold]streamlit run st.py[/bold]\n" + - t("Note: First startup may take up to 1 minute") - ) - console.print(Panel(panel1_text, style="bold green")) - - # Second panel with troubleshooting tips - panel2_text = ( - t("If the application fails to start:") + "\n" + - "1. " + t("Check your network connection") + "\n" + - "2. " + t("Re-run the installer: [bold]python install.py[/bold]") - ) - console.print(Panel(panel2_text, style="yellow")) - - # start the application - subprocess.Popen([sys.executable, "-m", "streamlit", "run", "st.py"]) if __name__ == "__main__": - main() + args = sys.argv[1:] + if "--check" not in args and "--launch" not in args and "--no-launch" not in args: + args.append("--launch") + raise SystemExit(main(args)) diff --git a/installer.py b/installer.py new file mode 100644 index 00000000..bfd82c55 --- /dev/null +++ b/installer.py @@ -0,0 +1,446 @@ +"""Resumable VideoLingo installer and environment checker. + +This script is intentionally split from setup_env.py: +- setup_env.py creates/selects the venv. +- installer.py installs packages inside the selected venv. +- OneKeyStart.bat starts the app and can call ``installer.py --check``. + +The installer is stage-based and safe to rerun. Network-sensitive optional +packages (Demucs, spaCy model downloads) warn instead of breaking the whole +installation. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib +import importlib.metadata as metadata +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent +STATE_FILE = Path(sys.prefix) / ".videolingo-install.json" +REQUIREMENTS = ROOT / "requirements.txt" + +TORCH_VERSION = "2.8.0" +TORCH_INDEX = "https://download.pytorch.org/whl" +BOOTSTRAP_PACKAGES = ["requests", "rich", "ruamel.yaml", "InquirerPy", "packaging"] +FILTERED_REQUIREMENTS = {"spacy", "whisperx"} +DEMUX_GIT = "demucs[dev]@git+https://github.com/adefossez/demucs@b9ab48cad45976ba42b2ff17b229c071f0df9390" + + +def run(cmd: list[str], retries: int = 0, env: dict[str, str] | None = None) -> None: + for attempt in range(retries + 1): + print(" > " + " ".join(str(x) for x in cmd), flush=True) + proc = subprocess.run(cmd, cwd=ROOT, env=env) + if proc.returncode == 0: + return + if attempt < retries: + delay = min(20, 3 * (attempt + 1)) + print(f" Command failed, retrying in {delay}s ({attempt + 1}/{retries})...") + time.sleep(delay) + raise subprocess.CalledProcessError(proc.returncode, cmd) + + +def pip_install(packages: list[str], retries: int = 2, extra_args: list[str] | None = None) -> None: + if not packages: + return + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--prefer-binary", + "--retries", + "5", + "--timeout", + "120", + ] + if extra_args: + cmd.extend(extra_args) + cmd.extend(packages) + env = os.environ.copy() + env.setdefault("PIP_NO_INPUT", "1") + run(cmd, retries=retries, env=env) + + +def soft_pip_install(packages: list[str], retries: int = 1, extra_args: list[str] | None = None) -> bool: + try: + pip_install(packages, retries=retries, extra_args=extra_args) + return True + except Exception as exc: + print(f" Warning: optional install failed: {exc}") + return False + + +def package_version(name: str) -> str | None: + try: + return metadata.version(name) + except metadata.PackageNotFoundError: + return None + + +def package_ok(name: str, prefix: str | None = None) -> bool: + version = package_version(name) + if version is None: + return False + return prefix is None or version.split("+")[0].startswith(prefix) + + +def import_ok(module: str) -> bool: + try: + importlib.import_module(module) + return True + except Exception: + return False + + +def requirements_hash() -> str: + h = hashlib.sha256() + h.update(REQUIREMENTS.read_bytes()) + h.update(f"torch={TORCH_VERSION}\n".encode()) + h.update(DEMUX_GIT.encode()) + return h.hexdigest() + + +def load_state() -> dict: + if not STATE_FILE.exists(): + return {} + try: + return json.loads(STATE_FILE.read_text(encoding="utf-8")) + except Exception: + return {} + + +def save_state() -> None: + data = { + "requirements_hash": requirements_hash(), + "python": sys.version.split()[0], + "torch": package_version("torch"), + "torchaudio": package_version("torchaudio"), + "spacy": package_version("spacy"), + "whisperx": package_version("whisperx"), + "demucs": package_version("demucs"), + "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), + } + STATE_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def requirement_name(line: str) -> str | None: + line = line.strip() + if not line or line.startswith("#") or line.startswith("-"): + return None + line = line.split(";", 1)[0].strip() + name = re.split(r"\s*(?:==|>=|<=|~=|!=|>|<|\[)", line, maxsplit=1)[0] + return name.strip().lower().replace("_", "-") or None + + +def read_base_requirements() -> list[str]: + reqs: list[str] = [] + for raw in REQUIREMENTS.read_text(encoding="utf-8").splitlines(): + name = requirement_name(raw) + if not name or name in FILTERED_REQUIREMENTS: + continue + reqs.append(raw.strip()) + return reqs + + +def detect_nvidia_gpu() -> bool: + if platform.system() == "Darwin": + return False + try: + result = subprocess.run(["nvidia-smi"], capture_output=True, text=True, timeout=10) + return result.returncode == 0 + except Exception: + return False + + +def detect_cuda_version_from_smi() -> tuple[int, int] | None: + try: + result = subprocess.run(["nvidia-smi"], capture_output=True, text=True, timeout=10) + match = re.search(r"CUDA Version:\s*(\d+)\.(\d+)", result.stdout) + if match: + return int(match.group(1)), int(match.group(2)) + except Exception: + pass + return None + + +def detect_torch_index() -> str: + cuda_version = detect_cuda_version_from_smi() + tags = [ + ((13, 0), "cu129"), + ((12, 9), "cu129"), + ((12, 8), "cu128"), + ((12, 6), "cu126"), + ] + if cuda_version: + for minimum, tag in tags: + if cuda_version >= minimum: + return f"{TORCH_INDEX}/{tag}" + return f"{TORCH_INDEX}/cu126" + + +def install_bootstrap() -> None: + print("\n[1/7] Bootstrap installer packages") + missing = [pkg for pkg in BOOTSTRAP_PACKAGES if package_version(pkg) is None] + if missing: + pip_install(missing) + else: + print(" Bootstrap packages already installed.") + + +def maybe_configure_mirror(auto_mirror: bool) -> None: + if not auto_mirror: + return + print("\n[2/7] Configure PyPI mirror") + try: + from core.utils.pypi_autochoose import main as choose_mirror + + choose_mirror() + except Exception as exc: + print(f" Warning: mirror auto-config failed: {exc}") + + +def install_torch(force: bool = False) -> None: + print("\n[3/7] Install PyTorch / torchaudio") + if not force and package_ok("torch", TORCH_VERSION) and package_ok("torchaudio", TORCH_VERSION): + print(f" torch {package_version('torch')} and torchaudio {package_version('torchaudio')} already installed.") + return + packages = [f"torch=={TORCH_VERSION}", f"torchaudio=={TORCH_VERSION}"] + if detect_nvidia_gpu(): + index = detect_torch_index() + print(f" NVIDIA GPU detected. Using PyTorch index: {index}") + pip_install(packages, retries=3, extra_args=["--index-url", index]) + else: + print(" No NVIDIA GPU detected. Installing CPU PyTorch wheels.") + pip_install(packages, retries=3) + + +def install_base_requirements(force: bool = False) -> None: + print("\n[4/7] Install base requirements") + state = load_state() + current_hash = requirements_hash() + previous_hash = state.get("requirements_hash") + if not force and previous_hash == current_hash and health_check(quiet=True, require_demucs=False, check_state=False) == 0: + print(" Environment already matches requirements hash; skipping base install.") + return + if not force and previous_hash is None and health_check(quiet=True, require_demucs=False, check_state=False) == 0: + print(" Packages are already healthy; writing fresh install state later.") + return + if previous_hash and previous_hash != current_hash: + print(" requirements.txt changed; syncing base requirements.") + pip_install(read_base_requirements(), retries=3) + + +def install_spacy(force: bool = False) -> None: + print("\n[5/7] Install spaCy") + if not force and package_ok("spacy", "3.8."): + print(f" spacy {package_version('spacy')} already installed.") + return + # Keep this flexible. Exact spaCy patch releases can disappear for a Python + # minor version, which made plain `pip install -r requirements.txt` brittle. + pip_install(["spacy>=3.8.7,<3.9"], retries=3) + + +def install_whisperx(force: bool = False) -> None: + print("\n[6/7] Install WhisperX") + if not force and package_version("whisperx") is not None: + print(f" whisperx {package_version('whisperx')} already installed.") + return + pip_install(["whisperx>=3.8.1"], retries=3) + + +def install_demucs(force: bool = False, require: bool = False) -> None: + print("\n[7/7] Install Demucs (optional)") + if not force and package_version("demucs") is not None and import_ok("demucs.api"): + print(f" demucs {package_version('demucs')} already installed.") + return + pip_install(["dora-search", "openunmix", "lameenc"], retries=3) + if soft_pip_install([DEMUX_GIT], retries=2, extra_args=["--no-deps"]): + return + print(" Falling back to PyPI demucs. Demucs is optional; install can continue if this fails.") + ok = soft_pip_install(["demucs==4.0.1"], retries=2, extra_args=["--no-deps"]) + if require and not ok: + raise RuntimeError("Demucs installation failed") + + +def install_project_metadata() -> None: + print("\n[post] Register project metadata (no dependency resolution)") + soft_pip_install(["-e", str(ROOT)], retries=1, extra_args=["--no-deps"]) + + +def check_ffmpeg() -> bool: + if not shutil.which("ffmpeg"): + print(" ERROR: ffmpeg not found in PATH.") + if platform.system() == "Windows": + print(" Install with: winget install Gyan.FFmpeg") + elif platform.system() == "Darwin": + print(" Install with: brew install ffmpeg") + else: + print(" Install with your distribution package manager, e.g. sudo apt install ffmpeg") + return False + return True + + +def noto_cjk_font_available() -> bool: + if platform.system() != "Linux" or not shutil.which("fc-match"): + return False + result = subprocess.run( + ["fc-match", "NotoSansCJK-Regular"], + capture_output=True, + text=True, + ) + output = f"{result.stdout} {result.stderr}".lower() + return result.returncode == 0 and "noto" in output and "cjk" in output + + +def _privileged_command(cmd: list[str]) -> list[str] | None: + if hasattr(os, "geteuid") and os.geteuid() == 0: + return cmd + if shutil.which("sudo"): + return ["sudo", *cmd] + return None + + +def install_linux_noto_fonts() -> None: + if platform.system() != "Linux": + return + print("\n[post] Check Linux Noto CJK fonts") + if noto_cjk_font_available(): + print(" Noto CJK fonts already installed.") + return + + if os.path.exists("/etc/debian_version"): + cmd = ["apt-get", "install", "-y", "fonts-noto-cjk"] + elif shutil.which("dnf"): + cmd = ["dnf", "install", "-y", "google-noto-sans-cjk-fonts"] + elif shutil.which("yum"): + cmd = ["yum", "install", "-y", "google-noto-sans-cjk-fonts"] + elif shutil.which("pacman"): + cmd = ["pacman", "-S", "--noconfirm", "noto-fonts-cjk"] + else: + print(" Warning: unsupported Linux distribution; please install Noto CJK fonts manually.") + return + + cmd = _privileged_command(cmd) + if cmd is None: + print(" Warning: sudo not found; please install Noto CJK fonts manually.") + return + + try: + run(cmd) + if shutil.which("fc-cache"): + subprocess.run(["fc-cache", "-f"], check=False) + print(" Noto CJK fonts installed.") + except Exception as exc: + print(f" Warning: failed to install Noto CJK fonts automatically: {exc}") + + +def health_check(quiet: bool = False, require_demucs: bool = False, check_state: bool = True) -> int: + errors: list[str] = [] + warnings: list[str] = [] + state = load_state() + if check_state: + if state.get("requirements_hash") and state.get("requirements_hash") != requirements_hash(): + errors.append("requirements changed since the last install; rerun installer.py") + elif not state.get("requirements_hash"): + errors.append("install state file is missing; rerun installer.py once to enable change detection") + required = { + "streamlit": None, + "openai": None, + "pandas": None, + "torch": TORCH_VERSION, + "torchaudio": TORCH_VERSION, + "spacy": "3.8.", + "whisperx": None, + } + for package, prefix in required.items(): + version = package_version(package) + if version is None: + errors.append(f"missing package: {package}") + elif prefix and not version.split("+")[0].startswith(prefix): + errors.append(f"{package} version {version} does not match expected {prefix}*") + if require_demucs and package_version("demucs") is None: + errors.append("missing optional package required by flag: demucs") + elif package_version("demucs") is None: + warnings.append("demucs is not installed; vocal separation will be unavailable") + if platform.system() == "Linux" and not noto_cjk_font_available(): + warnings.append("Noto CJK fonts are not installed; CJK subtitle burn-in may fail") + if not shutil.which("ffmpeg"): + errors.append("ffmpeg not found in PATH") + if not quiet: + print("\nEnvironment check") + for package in ["streamlit", "torch", "torchaudio", "spacy", "whisperx", "demucs"]: + print(f" {package}: {package_version(package) or 'missing'}") + for warning in warnings: + print(f" WARN: {warning}") + for error in errors: + print(f" ERROR: {error}") + return 1 if errors else 0 + + +def launch_streamlit() -> int: + env = os.environ.copy() + env["PYTHONWARNINGS"] = "ignore" + return subprocess.run([sys.executable, "-m", "streamlit", "run", "st.py"], cwd=ROOT, env=env).returncode + + +def install_all(args: argparse.Namespace) -> int: + install_bootstrap() + maybe_configure_mirror(args.auto_mirror) + install_torch(force=args.force) + install_base_requirements(force=args.force) + install_spacy(force=args.force) + install_whisperx(force=args.force) + if not args.skip_demucs: + install_demucs(force=args.force, require=args.require_demucs) + install_project_metadata() + install_linux_noto_fonts() + ffmpeg_ok = check_ffmpeg() + save_state() + status = health_check(require_demucs=args.require_demucs) + if not ffmpeg_ok or status != 0: + return 1 + if args.launch: + return launch_streamlit() + print("\nInstall complete. Start with OneKeyStart.bat or: python -m streamlit run st.py") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Install or check VideoLingo dependencies") + parser.add_argument("--check", action="store_true", help="check environment health only") + parser.add_argument("--quiet", action="store_true", help="quiet check output") + parser.add_argument("--force", action="store_true", help="force reinstall staged packages") + parser.add_argument("--auto-mirror", action="store_true", help="auto-select and configure a PyPI mirror") + parser.add_argument("--skip-demucs", action="store_true", help="skip optional Demucs install") + parser.add_argument("--require-demucs", action="store_true", help="fail if Demucs cannot be installed") + parser.add_argument("--launch", action="store_true", help="launch Streamlit after a successful install") + parser.add_argument("--yes", action="store_true", help="accepted for non-interactive wrappers") + parser.add_argument("--no-launch", action="store_true", help="compatibility alias; launching is opt-in") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.no_launch: + args.launch = False + if args.check: + return health_check(quiet=args.quiet, require_demucs=args.require_demucs) + return install_all(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/requirements.txt b/requirements.txt index 2050d02c..cd30fb20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,9 @@ PyYAML==6.0.3 replicate==0.33.0 requests==2.32.5 resampy==0.4.3 -spacy==3.8.11 +# Keep spaCy on the 3.8 line. Exact patch pins can be unavailable for a +# specific Python minor version on some mirrors, which makes setup brittle. +spacy>=3.8.7,<3.9 streamlit==1.49.1 streamlit-searchbox yt-dlp diff --git a/setup.py b/setup.py index b1021893..f122e3e7 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup, find_packages NAME = 'VideoLingo' -VERSION = '3.0.2' +VERSION = '3.0.3' with open('requirements.txt', encoding='utf-8') as f: requirements = f.read().splitlines() diff --git a/setup_env.py b/setup_env.py index da097336..657ab900 100644 --- a/setup_env.py +++ b/setup_env.py @@ -1,221 +1,181 @@ -""" -VideoLingo Environment Setup (No Anaconda Required) - -This script provides a conda-free installation path using `uv` (by Astral). -It automatically: - 1. Installs uv if not found - 2. Creates a .venv with Python 3.10 - 3. Runs install.py inside the venv +"""Create a VideoLingo Python environment, then run the stage-based installer. -Usage: - python setup_env.py # Full setup (any system Python 3.x works) - python setup_env.py --skip-install # Only create venv, don't run install.py +Default behavior creates a project-local ``.venv``. Use ``--shared`` to create +or reuse ``~/.venvs/videolingo`` so multiple VideoLingo checkouts share the same +heavy dependencies (PyTorch, WhisperX, Demucs, etc.). """ +from __future__ import annotations + +import argparse import os -import sys +import platform import shutil import subprocess -import platform +import sys +from pathlib import Path + PYTHON_VERSION = "3.10" -VENV_DIR = ".venv" -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +SCRIPT_DIR = Path(__file__).resolve().parent +LOCAL_VENV = SCRIPT_DIR / ".venv" +SHARED_VENV = Path.home() / ".venvs" / "videolingo" -def run(cmd, check=True, **kwargs): - """Run a command and return the CompletedProcess.""" - print(f" > {' '.join(cmd) if isinstance(cmd, list) else cmd}") +def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess: + print(" > " + " ".join(str(x) for x in cmd)) return subprocess.run(cmd, check=check, **kwargs) -def is_uv_installed(): - """Check if uv is available on PATH.""" +def is_uv_installed() -> bool: return shutil.which("uv") is not None -def install_uv(): - """Install uv using platform-appropriate method with fallbacks.""" - print("\n[1/3] Installing uv...") - +def install_uv() -> None: + print("\n[1/3] Checking uv") if is_uv_installed(): - ver = subprocess.run( - ["uv", "--version"], capture_output=True, text=True - ).stdout.strip() + ver = subprocess.run(["uv", "--version"], capture_output=True, text=True).stdout.strip() print(f" uv is already installed: {ver}") return - system = platform.system() - if system == "Windows": - _install_uv_windows() + if platform.system() == "Windows": + methods = [ + ["winget", "install", "astral-sh.uv", "--accept-package-agreements", "--accept-source-agreements"], + ["powershell", "-ExecutionPolicy", "ByPass", "-c", "irm https://astral.sh/uv/install.ps1 | iex"], + [sys.executable, "-m", "pip", "install", "uv"], + ] else: - # macOS / Linux - try: - run(["sh", "-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"]) - except subprocess.CalledProcessError: - print(" curl installer failed, trying pip...") - run([sys.executable, "-m", "pip", "install", "uv"]) - - # After installation, uv may not be on PATH in the current session. - if not is_uv_installed(): - _add_uv_to_path() - - if not is_uv_installed(): - print( - "\n*** ERROR: uv was installed but not found on PATH. ***\n" - "Please restart your terminal and run this script again.\n" - "Or install uv manually: https://docs.astral.sh/uv/getting-started/installation/" - ) - sys.exit(1) - - ver = subprocess.run( - ["uv", "--version"], capture_output=True, text=True - ).stdout.strip() - print(f" uv installed successfully: {ver}") - - -def _install_uv_windows(): - """Try multiple methods to install uv on Windows.""" - methods = [ - ("winget", ["winget", "install", "astral-sh.uv", - "--accept-package-agreements", "--accept-source-agreements"]), - ("PowerShell installer", [ - "powershell", "-ExecutionPolicy", "ByPass", "-c", - "irm https://astral.sh/uv/install.ps1 | iex" - ]), - ("pip", [sys.executable, "-m", "pip", "install", "uv"]), - ] + methods = [ + ["sh", "-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"], + [sys.executable, "-m", "pip", "install", "uv"], + ] - for name, cmd in methods: + for cmd in methods: try: - print(f" Trying {name}...") run(cmd) - # Check if PATH needs updating after install - if not is_uv_installed(): - _add_uv_to_path() + add_uv_to_path() if is_uv_installed(): + print(" uv installed successfully") return except (subprocess.CalledProcessError, FileNotFoundError): - print(f" {name} failed, trying next method...") - continue + print(" install method failed, trying next method...") - print(" All installation methods failed.") + raise SystemExit("ERROR: uv could not be installed. Install it manually: https://docs.astral.sh/uv/") -def _add_uv_to_path(): - """Try to add uv's default install location to PATH for this session.""" - home = os.path.expanduser("~") +def add_uv_to_path() -> None: candidates = [ - os.path.join(home, ".local", "bin"), - os.path.join(home, ".cargo", "bin"), - os.path.join(os.environ.get("LOCALAPPDATA", ""), "uv", "bin"), - os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "uv"), + Path.home() / ".local" / "bin", + Path.home() / ".cargo" / "bin", + Path(os.environ.get("LOCALAPPDATA", "")) / "uv" / "bin", + Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "uv", + Path(os.environ.get("LOCALAPPDATA", "")) / "Microsoft" / "WinGet" / "Links", ] - for p in candidates: - if not os.path.isdir(p): - continue - uv_name = "uv.exe" if platform.system() == "Windows" else "uv" - if os.path.isfile(os.path.join(p, uv_name)): - os.environ["PATH"] = p + os.pathsep + os.environ["PATH"] + name = "uv.exe" if platform.system() == "Windows" else "uv" + for path in candidates: + if (path / name).is_file(): + os.environ["PATH"] = str(path) + os.pathsep + os.environ.get("PATH", "") return -def create_venv(): - """Create a virtual environment with Python 3.10 using uv.""" - print(f"\n[2/3] Creating virtual environment with Python {PYTHON_VERSION}...") - - venv_path = os.path.join(SCRIPT_DIR, VENV_DIR) - - if os.path.exists(venv_path): - # Check if existing venv has the right Python version - python_exe = _get_venv_python(venv_path) - if python_exe and os.path.isfile(python_exe): - result = subprocess.run( - [python_exe, "--version"], capture_output=True, text=True - ) - ver = result.stdout.strip() - if "3.10" in ver: - print(f" .venv already exists with {ver}, reusing it.") - return python_exe - - print(" Removing existing .venv (wrong Python version)...") - shutil.rmtree(venv_path, ignore_errors=True) - - # uv venv will auto-download Python 3.10 if not present - # --seed installs pip/setuptools into the venv (install.py needs pip) - run(["uv", "venv", "--seed", "--python", PYTHON_VERSION, VENV_DIR], cwd=SCRIPT_DIR) - - python_exe = _get_venv_python(venv_path) - if not python_exe or not os.path.isfile(python_exe): - print("*** ERROR: Failed to create virtual environment. ***") - sys.exit(1) - - result = subprocess.run( - [python_exe, "--version"], capture_output=True, text=True - ) - print(f" Virtual environment created: {result.stdout.strip()}") - return python_exe - - -def _get_venv_python(venv_path): - """Get the Python executable path inside the venv.""" +def venv_python(venv_path: Path) -> Path: if platform.system() == "Windows": - return os.path.join(venv_path, "Scripts", "python.exe") - else: - return os.path.join(venv_path, "bin", "python") - + return venv_path / "Scripts" / "python.exe" + return venv_path / "bin" / "python" -def run_install(python_exe): - """Run install.py using the venv's Python.""" - print("\n[3/3] Running install.py...") - install_script = os.path.join(SCRIPT_DIR, "install.py") - # Prepare env for install.py subprocess: - env = os.environ.copy() - # 1. Avoid pip cache permission errors (common on Windows when cache dir - # is locked or has restrictive ACLs from a previous Python install) - env["PIP_NO_CACHE_DIR"] = "1" - # 2. Put venv Scripts/bin on PATH so install.py can find streamlit etc. - venv_path = os.path.join(SCRIPT_DIR, VENV_DIR) +def venv_bin(venv_path: Path) -> Path: if platform.system() == "Windows": - venv_bin = os.path.join(venv_path, "Scripts") - else: - venv_bin = os.path.join(venv_path, "bin") - env["PATH"] = venv_bin + os.pathsep + env.get("PATH", "") + return venv_path / "Scripts" + return venv_path / "bin" + + +def python_version_ok(python_exe: Path) -> bool: + if not python_exe.is_file(): + return False + result = subprocess.run([str(python_exe), "--version"], capture_output=True, text=True) + return "3.10" in (result.stdout or result.stderr) + + +def create_venv(path: Path, yes: bool = False) -> Path: + print(f"\n[2/3] Creating/reusing virtual environment: {path}") + python_exe = venv_python(path) + if python_version_ok(python_exe): + result = subprocess.run([str(python_exe), "--version"], capture_output=True, text=True) + print(f" Reusing existing venv: {result.stdout.strip() or result.stderr.strip()}") + return python_exe + + if path.exists(): + if not yes: + answer = input(f" Existing venv at {path} is not Python 3.10. Remove and recreate it? [y/N] ").strip().lower() + if answer != "y": + raise SystemExit("Cancelled.") + shutil.rmtree(path, ignore_errors=True) + + path.parent.mkdir(parents=True, exist_ok=True) + run(["uv", "venv", "--seed", "--python", PYTHON_VERSION, str(path)], cwd=SCRIPT_DIR) + if not python_version_ok(python_exe): + raise SystemExit("ERROR: failed to create a Python 3.10 virtual environment") + return python_exe - run([python_exe, install_script], cwd=SCRIPT_DIR, env=env) +def run_installer(python_exe: Path, args: argparse.Namespace) -> None: + print("\n[3/3] Installing VideoLingo dependencies") + env = os.environ.copy() + env["PATH"] = str(venv_bin(python_exe.parent.parent)) + os.pathsep + env.get("PATH", "") + cmd = [str(python_exe), str(SCRIPT_DIR / "installer.py"), "--yes"] + if args.auto_mirror: + cmd.append("--auto-mirror") + if args.force: + cmd.append("--force") + if args.skip_demucs: + cmd.append("--skip-demucs") + if args.require_demucs: + cmd.append("--require-demucs") + run(cmd, cwd=SCRIPT_DIR, env=env) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Create and install a VideoLingo environment") + parser.add_argument("--shared", action="store_true", help=f"use shared venv at {SHARED_VENV}") + parser.add_argument("--path", help="custom venv path; implies --shared-style external venv") + parser.add_argument("--skip-install", action="store_true", help="only create/reuse the venv") + parser.add_argument("--auto-mirror", action="store_true", help="auto-select a PyPI mirror before install") + parser.add_argument("--skip-demucs", action="store_true", help="skip optional Demucs install") + parser.add_argument("--require-demucs", action="store_true", help="fail if Demucs cannot be installed") + parser.add_argument("--force", action="store_true", help="force reinstall staged packages") + parser.add_argument("--yes", action="store_true", help="non-interactive; recreate wrong-version venvs") + return parser + + +def main() -> None: + args = build_parser().parse_args() + target = Path(args.path).expanduser() if args.path else (SHARED_VENV if args.shared else LOCAL_VENV) -def main(): print("=" * 60) - print(" VideoLingo Environment Setup (conda-free)") + print(" VideoLingo Environment Setup") print("=" * 60) - print(f"\n Project dir : {SCRIPT_DIR}") + print(f" Project dir : {SCRIPT_DIR}") print(f" Python ver : {PYTHON_VERSION}") - print(f" Venv dir : {VENV_DIR}") - - skip_install = "--skip-install" in sys.argv + print(f" Venv path : {target}") install_uv() - python_exe = create_venv() + python_exe = create_venv(target, yes=args.yes) - if skip_install: - print(f"\n --skip-install: Skipping install.py") - print(f"\n To install dependencies manually:") - print(f" {python_exe} install.py") + if args.skip_install: + print("\n --skip-install: dependencies were not installed") + print(f" To install later: {python_exe} {SCRIPT_DIR / 'installer.py'} --yes") else: - run_install(python_exe) + run_installer(python_exe, args) print("\n" + "=" * 60) - print(" Setup complete!") + print(" Setup complete") print("=" * 60) - print(f"\n To start VideoLingo:") if platform.system() == "Windows": - print(f" .venv\\Scripts\\streamlit run st.py") - print(f" (or double-click OneKeyStart_uv.bat)") + print(" Start with: OneKeyStart.bat") else: - print(f" .venv/bin/streamlit run st.py") - print() + streamlit = venv_bin(target) / "streamlit" + print(f" Start with: {streamlit} run st.py") if __name__ == "__main__":