mirror of
https://github.com/Luchexx/claude-video.git
synced 2026-11-02 17:29:52 +00:00
Add opt-in local transcription with managed installation, cache warm-up, and a one-time backend choice while preserving native-caption priority. Correct frame timing and coverage, caption provenance and download ownership, configuration parsing, provider pairing, and partial reports. Update cross-host instructions, packaging, and test-gated release CI. Validate 198 isolated tests, real local English and Spanish inference, network-denied warm-cache inference, live captions/media, and packaging. Record platform and account-dependent acceptance limits separately.
313 lines
14 KiB
Python
Executable File
313 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Base preflight, backend configuration, and an opt-in managed WhisperX install."""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from config import (CONFIG_DIR, CONFIG_FILE, ConfigError, DETAILS, get_config,
|
|
load_api_key, read_env_file, write_settings)
|
|
from runtime import configure_stdio, diagnostic, run_text
|
|
|
|
REQUIRED_BINARIES = ['ffmpeg', 'ffprobe', 'yt-dlp']
|
|
WHISPERX_VERSION = '3.8.6'
|
|
_PERM_WARNED: set[str] = set()
|
|
ENV_TEMPLATE = '''# /watch configuration. No shell interpolation; last assignment wins.
|
|
# Native captions always come first. Optional fallback: auto|whisperx|groq|openai|none.
|
|
# auto preserves the Groq-then-OpenAI preference for existing installations.
|
|
# The first-run skill wizard sets WATCH_DETAIL and WATCH_WHISPER_BACKEND.
|
|
GROQ_API_KEY=
|
|
OPENAI_API_KEY=
|
|
'''
|
|
|
|
|
|
def _which(name):
|
|
return shutil.which(name)
|
|
|
|
|
|
def _check_binaries():
|
|
return [name for name in REQUIRED_BINARIES if not _which(name)]
|
|
|
|
|
|
def _check_file_permissions(path: Path) -> None:
|
|
# Native Windows mode bits do not describe NTFS ACLs. WSL Linux homes do.
|
|
if os.name == 'nt' or platform.system() == 'Windows' or str(path) in _PERM_WARNED:
|
|
return
|
|
try:
|
|
if path.stat().st_mode & 0o044:
|
|
_PERM_WARNED.add(str(path))
|
|
print(f'[watch] WARNING: {path} is readable by other users. Run: chmod 600 "{path}"', file=sys.stderr)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _have_api_key():
|
|
backend, key = load_api_key()
|
|
return bool(key), backend
|
|
|
|
|
|
def is_first_run():
|
|
return read_env_file(CONFIG_FILE).get('SETUP_COMPLETE') != 'true'
|
|
|
|
|
|
def _scaffold_env():
|
|
if CONFIG_FILE.exists():
|
|
read_env_file(CONFIG_FILE) # Fail safely before modifying bad config.
|
|
return False
|
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
fd = os.open(CONFIG_FILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
with os.fdopen(fd, 'w', encoding='utf-8') as stream:
|
|
stream.write(ENV_TEMPLATE)
|
|
return True
|
|
|
|
|
|
def _write_setup_complete():
|
|
_scaffold_env()
|
|
write_settings({'SETUP_COMPLETE': 'true'}, CONFIG_FILE)
|
|
|
|
|
|
def _brew_pkg(missing):
|
|
return list(dict.fromkeys('ffmpeg' if name == 'ffprobe' else name for name in missing))
|
|
|
|
|
|
def _install_step(cmd, step):
|
|
print(f"[setup] {step}: {' '.join(map(str, cmd))}", file=sys.stderr, flush=True)
|
|
try:
|
|
result = subprocess.run(list(map(str, cmd)))
|
|
except OSError as exc:
|
|
raise SystemExit(f'{step}: cannot run {cmd[0]} ({type(exc).__name__}); install or repair that executable, then retry.') from None
|
|
if result.returncode:
|
|
raise SystemExit(f'{step} failed (exit {result.returncode}): retry setup after checking the error above. '
|
|
'For WhisperX, allow at least 3 GB free disk and 8 GB RAM; package/model downloads need network access.')
|
|
|
|
|
|
def _install_macos(missing):
|
|
if not _which('brew'):
|
|
return False, 'Homebrew is missing. Install it from https://brew.sh, then rerun setup.'
|
|
_install_step(['brew', 'install', *_brew_pkg(missing)], 'Install media dependencies')
|
|
return True, 'Installed media dependencies with Homebrew.'
|
|
|
|
|
|
def _install_hint_linux(missing):
|
|
hints = []
|
|
if 'ffmpeg' in _brew_pkg(missing):
|
|
hints.append('apt: sudo apt install ffmpeg (or the equivalent package for your distribution)')
|
|
if 'yt-dlp' in missing:
|
|
hints.append('Install pipx, then: pipx install "yt-dlp[default,curl-cffi]"; pipx ensurepath. For YouTube also install Deno: https://deno.com/')
|
|
return '\n'.join(hints)
|
|
|
|
|
|
def _install_hint_windows(missing):
|
|
hints = []
|
|
if 'ffmpeg' in _brew_pkg(missing):
|
|
hints.append('winget install --id Gyan.FFmpeg --exact')
|
|
if 'yt-dlp' in missing:
|
|
hints.extend(['winget install --id yt-dlp.yt-dlp --exact', 'winget install --id DenoLand.Deno --exact'])
|
|
return '\n'.join(hints) + '\nReopen your terminal/agent after PATH changes.'
|
|
|
|
|
|
def _probe(cmd):
|
|
try:
|
|
result = run_text(cmd, timeout=20)
|
|
output = result.stdout or result.stderr
|
|
return {'ok': result.returncode == 0, 'version_line': output.splitlines()[0] if output else None, 'output': diagnostic(output, 2500)}
|
|
except SystemExit as exc:
|
|
return {'ok': False, 'output': str(exc)}
|
|
|
|
|
|
def _whisperx_status(cfg, detailed=False):
|
|
from local_whisperx import sentinel_for
|
|
value = cfg['whisperx_bin']
|
|
executable = Path(value) if value else None
|
|
ready = bool(executable and executable.is_absolute() and executable.is_file() and sentinel_for(executable).is_file())
|
|
if ready and detailed:
|
|
# Explicit detailed diagnostics may start the CLI. --check never does.
|
|
try:
|
|
from local_whisperx import child_env
|
|
result = run_text([str(executable), '--help'], timeout=30, env=child_env())
|
|
ready = result.returncode == 0 and '--no_align' in result.stdout
|
|
except SystemExit:
|
|
ready = False
|
|
return ready
|
|
|
|
|
|
def _status(detailed=False):
|
|
missing = _check_binaries()
|
|
_check_file_permissions(CONFIG_FILE)
|
|
cfg = get_config()
|
|
has_key, detected = _have_api_key()
|
|
chosen = cfg['whisper_backend']
|
|
backend = detected if chosen == 'auto' else chosen
|
|
local_ready = _whisperx_status(cfg, detailed)
|
|
backend_ready = local_ready if chosen == 'whisperx' else (bool(load_api_key(backend)[1]) if backend in ('groq', 'openai') else chosen == 'none')
|
|
result = {'status': 'needs_install' if missing else 'ready', 'can_proceed': not missing,
|
|
'first_run': is_first_run(), 'setup_complete': not is_first_run(),
|
|
'missing_binaries': missing, 'whisper_backend': backend, 'configured_backend': chosen,
|
|
'has_api_key': has_key, 'backend_ready': backend_ready,
|
|
'whisperx_ready': local_ready, 'whisperx_bin': cfg['whisperx_bin'],
|
|
'whisperx_model': cfg['whisperx_model'], 'config_file': str(CONFIG_FILE),
|
|
'watch_detail': cfg['detail'], 'platform': platform.system()}
|
|
if detailed:
|
|
tools = {}
|
|
for name in REQUIRED_BINARIES:
|
|
path = _which(name)
|
|
tools[name] = {'path': path, **(_probe([path, '--version' if name == 'yt-dlp' else '-version']) if path else {'ok': False})}
|
|
ytdlp = _which('yt-dlp')
|
|
result['tools'] = tools
|
|
result['youtube'] = {
|
|
'deno': _which('deno'), 'node': _which('node'),
|
|
'ejs': 'unknown (inspect the owning yt-dlp installation; no network probe)',
|
|
'impersonation': _probe([ytdlp, '--ignore-config', '--list-impersonate-targets']) if ytdlp else None,
|
|
'update_hint': 'Update the owning package: brew upgrade yt-dlp; pipx upgrade yt-dlp; or winget upgrade --id yt-dlp.yt-dlp --exact. Verify the executable path above.',
|
|
}
|
|
return result
|
|
|
|
|
|
def cmd_check():
|
|
# Optional credentials and local-model readiness never block base watch.
|
|
status = _status()
|
|
if status['can_proceed']:
|
|
return 0
|
|
print(f"[watch] Missing {', '.join(status['missing_binaries'])}. Run python3 \"{Path(__file__).resolve()}\".", file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
def cmd_json():
|
|
print(json.dumps(_status(detailed=True), indent=2))
|
|
return 0
|
|
|
|
|
|
def cmd_install(backend=None, detail=None):
|
|
missing = _check_binaries()
|
|
if missing:
|
|
system = platform.system()
|
|
if system == 'Darwin':
|
|
ok, message = _install_macos(missing)
|
|
print(f'[setup] {message}', file=sys.stderr)
|
|
if not ok or _check_binaries():
|
|
return 2
|
|
else:
|
|
print(_install_hint_windows(missing) if system == 'Windows' else _install_hint_linux(missing), file=sys.stderr)
|
|
return 2
|
|
_scaffold_env()
|
|
if detail:
|
|
write_settings({'WATCH_DETAIL': detail}, CONFIG_FILE)
|
|
if backend == 'whisperx':
|
|
return install_whisperx()
|
|
if backend:
|
|
write_settings({'WATCH_WHISPER_BACKEND': backend}, CONFIG_FILE)
|
|
if backend in ('groq', 'openai') and not load_api_key(backend)[1]:
|
|
print(f'[setup] Base watch is ready. Add {backend.upper()}_API_KEY privately to {CONFIG_FILE}, then rerun --backend {backend}.', file=sys.stderr)
|
|
return 3
|
|
_write_setup_complete()
|
|
print(f'[setup] Base watch is ready. Configuration: {CONFIG_FILE}')
|
|
return 0
|
|
|
|
|
|
def _ensure_uv():
|
|
name = 'uv.exe' if platform.system() == 'Windows' else 'uv'
|
|
installed = _which('uv')
|
|
fallback = Path.home() / '.local' / 'bin' / name
|
|
if installed or fallback.is_file():
|
|
return installed or str(fallback)
|
|
system = platform.system()
|
|
if system == 'Darwin':
|
|
if not _which('brew'):
|
|
raise SystemExit('WhisperX setup needs uv. Install Homebrew from https://brew.sh, then rerun setup.')
|
|
_install_step(['brew', 'install', 'uv'], 'Install uv')
|
|
elif system == 'Windows':
|
|
_install_step(['powershell', '-NoProfile', '-ExecutionPolicy', 'ByPass', '-c', 'irm https://astral.sh/uv/install.ps1 | iex'], 'Install uv')
|
|
elif system == 'Linux':
|
|
# Fetch first: a failed curl must not be hidden by a successful empty sh.
|
|
with tempfile.TemporaryDirectory(prefix='watch-uv-') as temporary:
|
|
script = Path(temporary) / 'install.sh'
|
|
_install_step(['curl', '-LsSf', 'https://astral.sh/uv/install.sh', '-o', script], 'Download uv installer')
|
|
_install_step(['sh', script], 'Install uv')
|
|
else:
|
|
raise SystemExit('Install uv from https://docs.astral.sh/uv/ before retrying WhisperX setup.')
|
|
installed = _which('uv')
|
|
if installed or fallback.is_file():
|
|
return installed or str(fallback)
|
|
raise SystemExit('uv installed but could not be found; reopen your terminal and rerun setup.')
|
|
|
|
|
|
def install_whisperx():
|
|
from local_whisperx import settings, transcribe_audio
|
|
print('[setup] Local WhisperX: requires 3 GB free disk, 8 GB RAM, a 64-bit CPU, and network access for setup. '
|
|
'Downloads about 1 GB of packages plus the 464 MB small model. macOS Apple Silicon is verified; '
|
|
'Intel macOS, Linux, Windows, and CUDA installs are untested.', file=sys.stderr, flush=True)
|
|
if not _which('ffmpeg'):
|
|
raise SystemExit('Install FFmpeg first, then rerun setup.py --install-whisperx.')
|
|
cfg = settings()
|
|
# Check the existing config before downloading or modifying an environment.
|
|
_scaffold_env()
|
|
uv = _ensure_uv()
|
|
venv = Path.home() / '.cache' / 'watch' / 'whisperx-venv'
|
|
sentinel = venv / '.deps-ok'
|
|
windows = platform.system() == 'Windows'
|
|
python = venv / ('Scripts/python.exe' if windows else 'bin/python')
|
|
executable = venv / ('Scripts/whisperx.exe' if windows else 'bin/whisperx')
|
|
ready = sentinel.is_file() and python.is_file() and executable.is_file()
|
|
if not ready:
|
|
if venv.is_symlink():
|
|
raise SystemExit('Managed WhisperX venv is a symlink; choose a regular managed directory before installing.')
|
|
if venv.exists():
|
|
shutil.rmtree(venv) # Only this fixed, installer-owned environment.
|
|
venv.parent.mkdir(parents=True, exist_ok=True)
|
|
_install_step([uv, 'venv', str(venv), '--python', '3.12'], 'Create WhisperX Python 3.12 environment')
|
|
if platform.system() in ('Linux', 'Windows'):
|
|
_install_step([uv, 'pip', 'install', '--python', python, 'torch==2.8.0', 'torchaudio==2.8.0',
|
|
'--index-url', 'https://download.pytorch.org/whl/cpu'], 'Install CPU PyTorch wheels')
|
|
_install_step([uv, 'pip', 'install', '--python', python, f'whisperx=={WHISPERX_VERSION}'], 'Install WhisperX')
|
|
# Invalidate before warm-up: an interrupted repair is always safe to rerun.
|
|
sentinel.unlink(missing_ok=True)
|
|
cfg['whisperx_bin'] = str(executable)
|
|
with tempfile.TemporaryDirectory(prefix='watch-warmup-') as temporary:
|
|
audio = Path(temporary) / 'silence.mp3'
|
|
_install_step(['ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i',
|
|
'anullsrc=r=16000:cl=mono', '-t', '2', '-acodec', 'libmp3lame', str(audio)], 'Create warm-up audio')
|
|
transcribe_audio(audio, cfg, require_ready=False)
|
|
resolved = run_text([str(uv), 'pip', 'list', '--python', str(python), '--format', 'json'], timeout=60)
|
|
if resolved.returncode:
|
|
raise SystemExit(f'Could not record the WhisperX install: {diagnostic(resolved.stderr)}')
|
|
packages = json.loads(resolved.stdout)
|
|
(venv / 'watch-install.json').write_text(json.dumps({'whisperx': WHISPERX_VERSION, 'model': cfg['whisperx_model'],
|
|
'platform': platform.system(), 'packages': packages}, indent=2), encoding='utf-8')
|
|
sentinel.write_text(WHISPERX_VERSION + '\n', encoding='utf-8')
|
|
write_settings({'WATCH_WHISPER_BACKEND': 'whisperx', 'WATCH_WHISPERX_BIN': str(executable),
|
|
'WATCH_WHISPERX_MODEL': cfg['whisperx_model'], 'SETUP_COMPLETE': 'true'}, CONFIG_FILE)
|
|
print('[setup] WhisperX is ready; packages and both model caches are warmed.', file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
mode = parser.add_mutually_exclusive_group()
|
|
mode.add_argument('--check', action='store_true')
|
|
mode.add_argument('--json', action='store_true')
|
|
mode.add_argument('--install-whisperx', action='store_true')
|
|
parser.add_argument('--backend', choices=['auto', 'whisperx', 'groq', 'openai', 'none'])
|
|
parser.add_argument('--detail', choices=sorted(DETAILS))
|
|
args = parser.parse_args()
|
|
try:
|
|
if args.check:
|
|
return cmd_check()
|
|
if args.json:
|
|
return cmd_json()
|
|
return cmd_install('whisperx' if args.install_whisperx else args.backend, args.detail)
|
|
except (ConfigError, OSError, ValueError) as exc:
|
|
print(f'[setup] {exc}', file=sys.stderr)
|
|
return 2
|
|
|
|
|
|
if __name__ == '__main__':
|
|
configure_stdio()
|
|
raise SystemExit(main())
|