claude-video/skills/watch/scripts/setup.py
bradautomates e90ef44c32 Implement v0.3.0 reliability fixes and managed WhisperX transcription
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.
2026-09-12 22:09:43 +10:00

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())