diff --git a/.github/workflows/publish-windows-alpha.yml b/.github/workflows/publish-windows-alpha.yml index 3a6a954..9f08fcd 100644 --- a/.github/workflows/publish-windows-alpha.yml +++ b/.github/workflows/publish-windows-alpha.yml @@ -108,6 +108,9 @@ jobs: - The build is unsigned, so Windows SmartScreen may show a warning. - Faster Whisper safely falls back from unsupported float16 GPUs to CPU. - Long-stream waveforms are generated with bounded memory through FFmpeg. + - Optional transcription engines that are not bundled are clearly disabled. + - OpenAI and xAI keys/models can be tested without sending transcript text. + - AI Editor shows the active provider and explains authentication failures. This payload was promoted only after native Windows CI started the packaged backend, rendered a vertical MP4 with ASS captions and a diff --git a/ROADMAP.md b/ROADMAP.md index bf2ed22..e171a5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,6 +13,13 @@ - Bug fixes from early users - Clearer local setup guidance +## v0.1.2 + +- Verifiable OpenAI and xAI API keys without sending a transcript +- Explicit active provider and friendly authentication errors +- Honest desktop transcription-engine availability +- Automatic Faster Whisper model guidance + ## v0.2.0 - Better AI clipping workflow diff --git a/backend/requirements.txt b/backend/requirements.txt index 71d7662..f24573f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,9 +6,9 @@ python-multipart>=0.0.12 # Transcription with word-level timestamps faster-whisper>=1.0.0 -# WhisperX already provides the default transcription path. The legacy -# openai-whisper fallback is optional because its source distribution currently -# fails in pip's isolated build environment on otherwise supported systems. +# Faster Whisper is the bundled/default desktop path. WhisperX and the legacy +# openai-whisper fallback are optional because their dependency stacks currently +# fail in pip's isolated build environment on otherwise supported systems. # Install it manually with: # pip install "setuptools<81" wheel && pip install --no-build-isolation openai-whisper # Optional Parakeet TDT v3 engine support uses NVIDIA NeMo: diff --git a/backend/routers/ai.py b/backend/routers/ai.py index 7fee8b7..b7e3a47 100644 --- a/backend/routers/ai.py +++ b/backend/routers/ai.py @@ -77,6 +77,13 @@ class ModelListRequest(BaseModel): api_key: Optional[str] = None +class ProviderCheckRequest(BaseModel): + provider: str + api_key: Optional[str] = None + model: Optional[str] = None + base_url: Optional[str] = None + + @router.post("/ai/filler-removal") async def filler_removal(req: FillerRequest): try: @@ -217,3 +224,13 @@ async def ollama_status(base_url: str = "http://localhost:11434"): async def nine_router_models(req: ModelListRequest): models = AIProvider.list_9router_models(req.base_url or "http://localhost:20128/v1", req.api_key) return {"models": models} + + +@router.post("/ai/provider-check") +async def provider_check(req: ProviderCheckRequest): + return AIProvider.check_cloud_provider( + provider=req.provider, + api_key=req.api_key, + model=req.model, + base_url=req.base_url, + ) diff --git a/backend/routers/transcribe.py b/backend/routers/transcribe.py index 54043fb..a89428d 100644 --- a/backend/routers/transcribe.py +++ b/backend/routers/transcribe.py @@ -36,7 +36,20 @@ def progress(percent: int, message: str): progress_callback(percent, message) try: - progress(5, "Preparing transcription") + engine_label = { + "auto": "the best available transcription engine", + "faster-whisper": "Faster Whisper", + "whisperx": "WhisperX", + "whisper": "Whisper", + "parakeet": "Parakeet", + }.get(req.engine, req.engine) + progress( + 5, + ( + f"Loading {engine_label} model '{req.model}'. " + "On first use, an available engine downloads its speech model automatically." + ), + ) result = transcribe_audio( file_path=req.file_path, model_name=req.model, diff --git a/backend/scripts/smoke_backend.py b/backend/scripts/smoke_backend.py index bf46319..6431161 100644 --- a/backend/scripts/smoke_backend.py +++ b/backend/scripts/smoke_backend.py @@ -473,9 +473,23 @@ def test_transcription_engine_status_includes_parakeet(self) -> None: status = transcription.get_transcription_engine_status() self.assertIn("faster-whisper", status["engines"]) self.assertTrue(status["engines"]["faster-whisper"]["first_class"]) + self.assertIn("downloads automatically", status["engines"]["faster-whisper"]["download_behavior"]) self.assertIn("parakeet", status["engines"]) self.assertTrue(status["engines"]["parakeet"]["first_class"]) self.assertEqual(status["engines"]["parakeet"]["default_model"], transcription.PARAKEET_DEFAULT_MODEL) + if not status["engines"]["whisperx"]["available"]: + self.assertFalse(status["engines"]["whisperx"]["selectable"]) + self.assertIn("desktop build", status["engines"]["whisperx"]["unavailable_reason"]) + + def test_unavailable_whisperx_explains_that_manual_model_download_is_not_enough(self) -> None: + transcription = self._load_transcription_service_or_skip() + original_available = transcription.WHISPERX_AVAILABLE + try: + transcription.WHISPERX_AVAILABLE = False + with self.assertRaisesRegex(RuntimeError, "Downloading a Whisper model manually"): + transcription._resolve_engine("whisperx") + finally: + transcription.WHISPERX_AVAILABLE = original_available def test_faster_whisper_normalizes_word_timestamps(self) -> None: transcription = self._load_transcription_service_or_skip() @@ -801,6 +815,69 @@ def test_xai_provider_uses_official_openai_compatible_endpoint(self) -> None: self.assertEqual(args[1], "grok-4.5") self.assertEqual(args[3], "https://api.x.ai/v1") self.assertEqual(args[6], "xAI") + self.assertEqual(args[7], "xai") + + def test_cloud_provider_check_verifies_key_and_selected_model_without_completion(self) -> None: + response = SimpleNamespace( + ok=True, + status_code=200, + text="", + json=lambda: { + "object": "list", + "data": [ + {"id": "grok-4.5"}, + {"id": "grok-4.3"}, + ], + }, + ) + with patch.object(ai_provider.requests, "get", return_value=response) as request: + result = ai_provider.AIProvider.check_cloud_provider( + provider="xai", + api_key="xai-test", + model="grok-4.5", + ) + + self.assertTrue(result["ok"]) + self.assertTrue(result["authenticated"]) + self.assertTrue(result["model_available"]) + self.assertEqual(result["models"], ["grok-4.3", "grok-4.5"]) + self.assertEqual(request.call_args.args[0], "https://api.x.ai/v1/models") + self.assertEqual(request.call_args.kwargs["headers"]["Authorization"], "Bearer xai-test") + + def test_cloud_provider_check_explains_rejected_xai_key(self) -> None: + response = SimpleNamespace( + ok=False, + status_code=400, + text="", + json=lambda: { + "code": "invalid-argument", + "error": "Incorrect API key provided.", + }, + ) + with patch.object(ai_provider.requests, "get", return_value=response): + result = ai_provider.AIProvider.check_cloud_provider( + provider="xai", + api_key="xai-secret", + model="grok-4.5", + ) + + self.assertFalse(result["ok"]) + self.assertFalse(result["authenticated"]) + self.assertEqual(result["code"], "invalid_key") + self.assertIn("did reach xAI", result["message"]) + self.assertNotIn("xai-secret", str(result)) + + def test_completion_error_explains_openai_api_is_separate_from_chatgpt(self) -> None: + error = SimpleNamespace(status_code=401) + error.__str__ = lambda self: "Incorrect API key provided" + message = ai_provider._friendly_completion_error( + "openai", + "OpenAI", + RuntimeError("Incorrect API key provided"), + "gpt-4o", + ) + + self.assertIn("ChatGPT subscription does not include OpenAI API usage", message) def test_clip_request_includes_shorts_platform_guidance(self) -> None: captured: dict[str, str] = {} diff --git a/backend/services/ai_provider.py b/backend/services/ai_provider.py index ed410d9..1249156 100644 --- a/backend/services/ai_provider.py +++ b/backend/services/ai_provider.py @@ -10,6 +10,19 @@ logger = logging.getLogger(__name__) +_CLOUD_PROVIDER_CONFIG = { + "openai": { + "label": "OpenAI", + "base_url": "https://api.openai.com/v1", + "key_url": "https://platform.openai.com/api-keys", + }, + "xai": { + "label": "xAI", + "base_url": "https://api.x.ai/v1", + "key_url": "https://console.x.ai/", + }, +} + class AIProvider: """Routes completion requests to the configured provider.""" @@ -39,6 +52,7 @@ def complete( system_prompt, temperature, "xAI", + "xai", ) elif provider == "9router": return _nine_router_complete( @@ -106,6 +120,138 @@ def list_9router_models(base_url: str = "http://localhost:20128/v1", api_key: Op logger.error(f"9router model listing error: {e}") return [] + @staticmethod + def check_cloud_provider( + provider: str, + api_key: Optional[str], + model: Optional[str] = None, + base_url: Optional[str] = None, + ) -> dict: + """Verify a cloud key and selected model without making a completion request.""" + config = _CLOUD_PROVIDER_CONFIG.get(provider) + if not config: + return { + "ok": False, + "authenticated": False, + "provider": provider, + "code": "unsupported_provider", + "message": f"Connection testing is not available for provider '{provider}'.", + "models": [], + "model_available": None, + } + + provider_label = str(config["label"]) + key = (api_key or "").strip() + selected_model = (model or "").strip() + endpoint = _normalize_base_url(base_url or str(config["base_url"])) + if not key: + return { + "ok": False, + "authenticated": False, + "provider": provider, + "code": "missing_key", + "message": f"Enter a {provider_label} API key first.", + "models": [], + "model_available": None, + } + + try: + response = requests.get( + f"{endpoint}/models", + headers={"Authorization": f"Bearer {key}"}, + timeout=15, + ) + except requests.RequestException as error: + logger.warning("%s connection test failed: %s", provider_label, error) + return { + "ok": False, + "authenticated": False, + "provider": provider, + "code": "network_error", + "message": ( + f"Could not reach {provider_label}. Check the internet connection, " + "VPN/firewall, and try again." + ), + "models": [], + "model_available": None, + } + + if not response.ok: + error_text = _safe_provider_error_text(response, key) + code, message, authenticated = _classify_provider_error( + provider, + provider_label, + error_text, + response.status_code, + selected_model, + ) + logger.warning( + "%s connection test was rejected with status %s (%s)", + provider_label, + response.status_code, + code, + ) + return { + "ok": False, + "authenticated": authenticated, + "provider": provider, + "code": code, + "message": message, + "models": [], + "model_available": None, + } + + try: + payload = response.json() + except ValueError: + return { + "ok": False, + "authenticated": True, + "provider": provider, + "code": "invalid_response", + "message": f"{provider_label} accepted the key but returned an unreadable model list.", + "models": [], + "model_available": None, + } + + raw_models = payload.get("data", payload if isinstance(payload, list) else []) + models = sorted( + { + model_id + for model_id in (_extract_model_id(item) for item in raw_models) + if model_id + } + ) + model_available = selected_model in models if selected_model else None + if selected_model and not model_available: + return { + "ok": False, + "authenticated": True, + "provider": provider, + "code": "model_unavailable", + "message": ( + f"{provider_label} accepted the key, but model '{selected_model}' is not " + "available to this account. Choose one of the models loaded below." + ), + "models": models[:500], + "model_available": False, + } + + return { + "ok": True, + "authenticated": True, + "provider": provider, + "code": "ok", + "message": ( + f"{provider_label} connection verified. " + f"Model '{selected_model}' is available." + if selected_model + else f"{provider_label} connection verified." + ), + "models": models[:500], + "model_available": model_available, + } + def _normalize_base_url(base_url: Optional[str]) -> str: url = (base_url or "http://localhost:11434").strip() @@ -126,6 +272,114 @@ def _extract_model_id(model: object) -> Optional[str]: return None +def _safe_provider_error_text(response: requests.Response, api_key: str) -> str: + try: + payload = response.json() + text = json.dumps(payload, ensure_ascii=False) + except ValueError: + text = response.text + if api_key: + text = text.replace(api_key, "[redacted]") + return text[:1000] + + +def _classify_provider_error( + provider: str, + provider_label: str, + error_text: str, + status_code: int, + model: str = "", +) -> tuple[str, str, bool]: + lowered = error_text.lower() + key_url = str(_CLOUD_PROVIDER_CONFIG.get(provider, {}).get("key_url", "")) + if any( + marker in lowered + for marker in ( + "incorrect api key", + "invalid api key", + "invalid_api_key", + "authentication_error", + "unauthorized", + ) + ) or status_code == 401: + extra = ( + " A ChatGPT subscription does not include OpenAI API usage." + if provider == "openai" + else "" + ) + return ( + "invalid_key", + ( + f"{provider_label} rejected this API key before processing the transcript. " + f"The request did reach {provider_label}, but no completion tokens were used." + f"{extra} Create a new API key at {key_url} and test it in Settings." + ), + False, + ) + if status_code == 403 or any(marker in lowered for marker in ("permission", "forbidden", "acl")): + permission_hint = ( + " Make sure the key has access to the Models and Chat endpoints and to the selected model." + if provider == "xai" + else "" + ) + return ( + "permission_denied", + f"{provider_label} recognized the key but denied access.{permission_hint}", + True, + ) + if any( + marker in lowered + for marker in ( + "model_not_found", + "model not found", + "does not exist", + "not have access to model", + ) + ): + model_label = f" '{model}'" if model else "" + return ( + "model_unavailable", + ( + f"{provider_label} accepted the key, but model{model_label} is not available. " + "Open Settings, test the connection, and choose a returned model." + ), + True, + ) + if status_code == 429 or any(marker in lowered for marker in ("quota", "billing", "rate limit")): + return ( + "quota_or_rate_limit", + ( + f"{provider_label} accepted the request but the API account has no available " + "quota, billing, or rate-limit capacity." + ), + True, + ) + return ( + "provider_error", + f"{provider_label} rejected the request (HTTP {status_code}). Test the connection in Settings.", + False, + ) + + +def _friendly_completion_error( + provider: str, + provider_name: str, + error: Exception, + model: str, +) -> str: + status_code = int(getattr(error, "status_code", 0) or 0) + code, message, _authenticated = _classify_provider_error( + provider, + provider_name, + str(error), + status_code, + model, + ) + if code != "provider_error": + return message + return f"{provider_name} request failed. Test the active provider in Settings and try again." + + def _ollama_complete(prompt: str, model: str, base_url: str, system_prompt: Optional[str], temperature: float) -> str: base_url = _normalize_base_url(base_url) body = { @@ -147,7 +401,16 @@ def _ollama_complete(prompt: str, model: str, base_url: str, system_prompt: Opti def _openai_complete(prompt: str, model: str, api_key: str, system_prompt: Optional[str], temperature: float) -> str: - return _openai_compatible_complete(prompt, model, api_key, None, system_prompt, temperature, "OpenAI") + return _openai_compatible_complete( + prompt, + model, + api_key, + None, + system_prompt, + temperature, + "OpenAI", + "openai", + ) def _openai_compatible_complete( @@ -158,6 +421,7 @@ def _openai_compatible_complete( system_prompt: Optional[str], temperature: float, provider_name: str, + provider: str = "openai", ) -> str: try: from openai import OpenAI @@ -177,8 +441,9 @@ def _openai_compatible_complete( ) return response.choices[0].message.content.strip() except Exception as e: - logger.error(f"{provider_name} error: {e}") - raise + friendly_error = _friendly_completion_error(provider, provider_name, e, model) + logger.error("%s request failed: %s", provider_name, friendly_error) + raise RuntimeError(friendly_error) from e def _nine_router_complete( diff --git a/backend/services/transcription.py b/backend/services/transcription.py index be94e24..795f10c 100644 --- a/backend/services/transcription.py +++ b/backend/services/transcription.py @@ -160,14 +160,22 @@ def _resolve_engine(engine: TranscriptionEngine) -> TranscriptionEngine: raise RuntimeError(f"Unknown transcription engine: {engine}") if engine == "parakeet" and not NEMO_AVAILABLE: raise RuntimeError( - "Parakeet TDT v3 is not available. Install NVIDIA NeMo ASR dependencies or choose WhisperX/Whisper." + "Parakeet TDT v3 is not included in this ScriptCut build. " + "Choose Faster Whisper; its speech model downloads automatically on first use." ) if engine == "faster-whisper" and not FASTER_WHISPER_AVAILABLE: raise RuntimeError("faster-whisper is not installed. Run the standard backend setup.") if engine == "whisperx" and not WHISPERX_AVAILABLE: - raise RuntimeError("WhisperX is not installed. Install whisperx or choose another transcription engine.") + raise RuntimeError( + "WhisperX is not included in this ScriptCut build. Downloading a Whisper model " + "manually will not install WhisperX. Choose Faster Whisper; its selected model " + "downloads automatically on first use." + ) if engine == "whisper" and not WHISPER_AVAILABLE: - raise RuntimeError("OpenAI Whisper is not installed. Install openai-whisper or choose another transcription engine.") + raise RuntimeError( + "Legacy Whisper is not included in this ScriptCut build. Choose Faster Whisper; " + "its selected model downloads automatically on first use." + ) return engine if NEMO_AVAILABLE: return "parakeet" @@ -214,29 +222,57 @@ def get_transcription_engine_status() -> dict: "engines": { "faster-whisper": { "available": FASTER_WHISPER_AVAILABLE, + "selectable": FASTER_WHISPER_AVAILABLE, "default_model": "base", "label": "Faster Whisper word timestamps", "first_class": True, + "download_behavior": "Selected speech model downloads automatically on first use.", + "unavailable_reason": ( + None + if FASTER_WHISPER_AVAILABLE + else "The core transcription package is missing from this installation." + ), }, "parakeet": { "available": NEMO_AVAILABLE, + "selectable": NEMO_AVAILABLE, "default_model": PARAKEET_DEFAULT_MODEL, "label": "Parakeet TDT v3 multilingual", "first_class": True, "languages": 25, "install_hint": "pip install -U nemo_toolkit['asr']", + "download_behavior": "Optional engine; not installed by downloading a speech model.", + "unavailable_reason": ( + None + if NEMO_AVAILABLE + else "Not included in this desktop build. Use Faster Whisper." + ), }, "whisperx": { "available": WHISPERX_AVAILABLE, + "selectable": WHISPERX_AVAILABLE, "default_model": "base", "label": "WhisperX aligned", "first_class": True, + "download_behavior": "Optional engine; not installed by downloading a Whisper model.", + "unavailable_reason": ( + None + if WHISPERX_AVAILABLE + else "Not included in this desktop build. Use Faster Whisper." + ), }, "whisper": { "available": WHISPER_AVAILABLE, + "selectable": WHISPER_AVAILABLE, "default_model": "base", "label": "Whisper fallback", "first_class": True, + "download_behavior": "Optional legacy engine.", + "unavailable_reason": ( + None + if WHISPER_AVAILABLE + else "Not included in this desktop build. Use Faster Whisper." + ), }, }, } diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index bfcf941..b189243 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -96,6 +96,39 @@ ollama list Cloud providers require valid API keys. ScriptCut keeps provider settings local. +### Grok/OpenAI says the API key is incorrect + +Update to ScriptCut 0.1.2 or newer, then: + +1. Open **More → Settings**. +2. Select the provider that should be used by AI Editor. +3. Enter its API key and model. +4. Click **Test connection**. + +Only the selected provider is used. Saving both an OpenAI key and an xAI key +does not send the same request to both providers. + +The connection test reads the models available to the key. It does not send +transcript text and does not use completion tokens. If xAI reports an incorrect +key, the request reached xAI but was rejected before model processing, so it may +not appear as billable usage. xAI keys also need access to the Models and Chat +endpoints and to the selected model. + +A ChatGPT Plus/Pro subscription and OpenAI API billing are separate. Create an +API key in the OpenAI API platform and make sure the API account has billing or +credits available. + +### WhisperX or another transcription engine does not download + +Update to ScriptCut 0.1.2 or newer. The desktop build includes Faster Whisper +and disables optional engines that are not installed. WhisperX is a separate +program dependency; downloading a `medium` model by itself cannot install it. + +For the normal Windows build choose **Faster Whisper** and then `base`, `small`, +or `medium`. The selected speech model downloads automatically on the first +transcription, so no manual model installation is required. The first run can +remain on the model-loading message while the download finishes. + ## Background Removal Is Disabled Background removal requires optional Python packages such as MediaPipe and OpenCV. Check availability in the export panel or by running: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7a8d40f..3821450 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "scriptcut-frontend", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scriptcut-frontend", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "lucide-react": "^0.468.0", "react": "^19.0.0", diff --git a/frontend/package.json b/frontend/package.json index daac054..26ec619 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "scriptcut-frontend", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 42dfe44..702a6e8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,11 +53,14 @@ type TranscriptionEngineStatus = { default_model?: string; engines?: Record; }; type SystemCheck = { @@ -73,29 +76,29 @@ type SystemChecksResponse = { const TRANSCRIPTION_MODELS: Record> = { auto: [ { value: 'base', label: 'Auto best available' }, - { value: 'small', label: 'small (~460 MB, better)' }, - { value: 'medium', label: 'medium (~1.5 GB, high accuracy)' }, + { value: 'small', label: 'small (better accuracy)' }, + { value: 'medium', label: 'medium (high accuracy, slower)' }, ], 'faster-whisper': [ - { value: 'tiny', label: 'tiny (~75 MB, fastest)' }, - { value: 'base', label: 'base (~140 MB, fast)' }, - { value: 'small', label: 'small (~460 MB, good)' }, - { value: 'medium', label: 'medium (~1.5 GB, better)' }, - { value: 'large-v3', label: 'large-v3 (~3 GB, best)' }, + { value: 'tiny', label: 'tiny (fastest)' }, + { value: 'base', label: 'base (recommended)' }, + { value: 'small', label: 'small (better accuracy)' }, + { value: 'medium', label: 'medium (high accuracy, slower)' }, + { value: 'large-v3', label: 'large-v3 (best, very slow)' }, ], whisperx: [ - { value: 'tiny', label: 'tiny (~75 MB, fastest)' }, - { value: 'base', label: 'base (~140 MB, fast)' }, - { value: 'small', label: 'small (~460 MB, good)' }, - { value: 'medium', label: 'medium (~1.5 GB, better)' }, - { value: 'large', label: 'large (~2.9 GB, best)' }, + { value: 'tiny', label: 'tiny (fastest)' }, + { value: 'base', label: 'base (fast)' }, + { value: 'small', label: 'small (good)' }, + { value: 'medium', label: 'medium (better)' }, + { value: 'large', label: 'large (best)' }, ], whisper: [ - { value: 'tiny', label: 'tiny (~75 MB, fastest)' }, - { value: 'base', label: 'base (~140 MB, fast)' }, - { value: 'small', label: 'small (~460 MB, good)' }, - { value: 'medium', label: 'medium (~1.5 GB, better)' }, - { value: 'large', label: 'large (~2.9 GB, best)' }, + { value: 'tiny', label: 'tiny (fastest)' }, + { value: 'base', label: 'base (fast)' }, + { value: 'small', label: 'small (good)' }, + { value: 'medium', label: 'medium (better)' }, + { value: 'large', label: 'large (best)' }, ], parakeet: [ { value: 'nvidia/parakeet-tdt-0.6b-v3', label: 'Parakeet TDT v3 multilingual' }, @@ -589,15 +592,29 @@ export default function App() { onChange={(e) => { const engine = e.target.value as TranscriptionEngine; setTranscriptionEngine(engine); - setTranscriptionModel(TRANSCRIPTION_MODELS[engine][0].value); + setTranscriptionModel( + transcriptionEngineStatus?.engines?.[engine]?.default_model || + TRANSCRIPTION_MODELS[engine].find((model) => model.value === 'base')?.value || + TRANSCRIPTION_MODELS[engine][0].value, + ); }} className="px-3 py-1.5 bg-editor-bg border border-editor-border rounded-md text-xs text-editor-text focus:outline-none focus:border-editor-accent" > - - - - + {([ + ['faster-whisper', 'Faster Whisper (recommended)'], + ['parakeet', 'Parakeet TDT v3 multilingual'], + ['whisperx', 'WhisperX aligned'], + ['whisper', 'Whisper fallback'], + ] as const).map(([engine, label]) => { + const engineInfo = transcriptionEngineStatus?.engines?.[engine]; + const unavailable = !!transcriptionEngineStatus && !engineInfo?.available; + return ( + + ); + })}