From 56d63a71456895c4151ba90397fb3fc75dabdef6 Mon Sep 17 00:00:00 2001
From: Alice
Date: Wed, 29 Jul 2026 21:43:49 +0800
Subject: [PATCH 1/2] Fix AI provider and transcription setup UX
---
.github/workflows/publish-windows-alpha.yml | 3 +
ROADMAP.md | 7 +
backend/requirements.txt | 6 +-
backend/routers/ai.py | 17 ++
backend/routers/transcribe.py | 15 +-
backend/scripts/smoke_backend.py | 77 ++++++
backend/services/ai_provider.py | 271 +++++++++++++++++++-
backend/services/transcription.py | 42 ++-
docs/TROUBLESHOOTING.md | 33 +++
frontend/package-lock.json | 4 +-
frontend/package.json | 2 +-
frontend/src/App.tsx | 82 +++---
frontend/src/components/AIPanel.tsx | 49 +++-
frontend/src/components/SettingsPanel.tsx | 188 +++++++++++++-
frontend/src/store/aiStore.ts | 17 +-
frontend/src/types/project.ts | 10 +
frontend/src/utils/releaseInfo.ts | 18 +-
package-lock.json | 4 +-
package.json | 2 +-
19 files changed, 779 insertions(+), 68 deletions(-)
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"
>
Auto best available
- Faster Whisper (recommended)
- Parakeet TDT v3 multilingual
- WhisperX aligned
- Whisper fallback
+ {([
+ ['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 (
+
+ {label}{unavailable ? ' — not included in this build' : ''}
+
+ );
+ })}
- {transcriptionEngine === 'parakeet' && !transcriptionEngineStatus?.engines?.parakeet?.available
- ? 'Parakeet needs its optional local package. Auto will choose the best available engine instead.'
- : transcriptionEngine === 'auto'
- ? 'Auto uses the best available local transcription engine.'
- : `${TRANSCRIPTION_MODELS[transcriptionEngine][0]?.label || 'Selected transcription engine'}.`}
+ {transcriptionEngine === 'auto'
+ ? 'Auto uses the best installed local engine. Its selected speech model downloads automatically on first use.'
+ : transcriptionEngineStatus?.engines?.[transcriptionEngine]?.available
+ ? transcriptionEngineStatus.engines[transcriptionEngine].download_behavior ||
+ 'The selected speech model downloads automatically on first use.'
+ : transcriptionEngineStatus?.engines?.[transcriptionEngine]?.unavailable_reason ||
+ 'This optional engine is not included in the desktop build. Choose Faster Whisper.'}
+ {transcriptionEngineStatus && (
+
+ Disabled engines are separate program components. Downloading a model file does not install them.
+
+ )}
{IS_ELECTRON ? (
@@ -690,7 +714,7 @@ export default function App() {
}
title="Edit full video"
- detail="Trim the transcript, then export"
+ detail="Open file and start transcription"
onClick={() => void handleOpenFile('full-video')}
/>
: }
title="Edit full video"
- detail="Choose a file from your folders"
+ detail="Choose file and start transcription"
onClick={() => void handleOpenFile('full-video')}
disabled={isBrowserUploading}
/>
diff --git a/frontend/src/components/AIPanel.tsx b/frontend/src/components/AIPanel.tsx
index bff0357..79cf325 100644
--- a/frontend/src/components/AIPanel.tsx
+++ b/frontend/src/components/AIPanel.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { useAIStore } from '../store/aiStore';
-import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, Plus, Users, Filter, Image, Clipboard, ExternalLink, ShieldAlert, VolumeX } from 'lucide-react';
+import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, Plus, Users, Filter, Image, Clipboard, ExternalLink, ShieldAlert, VolumeX, AlertTriangle } from 'lucide-react';
import type { CaptionStyle, ClipDraft, ClipDraftStatus, ClipSuggestion, EditPlanReviewDecision, EditPlanResult, EditPlanSuggestion, FillerReviewDecision, FillerWordResult, TopicSelectionSegment, Word } from '../types/project';
import {
getClipDraftReadinessScore,
@@ -121,6 +121,13 @@ const SHORTS_DRAFT_DEFAULTS = {
const EXPORTABLE_DRAFT_STATUSES = new Set(['draft', 'packaged', 'failed']);
const CLIP_EXPORT_DIRECTORY_KEY = 'scriptcut.clipExport.directory';
+const AI_PROVIDER_LABELS = {
+ ollama: 'Ollama',
+ openai: 'OpenAI',
+ claude: 'Claude',
+ xai: 'Grok (xAI)',
+ '9router': '9router',
+} as const;
function getClipQueueSummary(drafts: ClipDraft[]) {
return {
@@ -186,6 +193,7 @@ export default function AIPanel() {
const [fillerQueueFilter, setFillerQueueFilter] = useState('all');
const [fillerReasonFilter, setFillerReasonFilter] = useState('all');
const [activeAIJob, setActiveAIJob] = useState<(AIJob & AIJobContext) | null>(null);
+ const [aiActionError, setAIActionError] = useState('');
const [backgroundCapabilities, setBackgroundCapabilities] = useState(null);
const [activeClipDraftId, setActiveClipDraftId] = useState(null);
const [clipExportDirectory, setClipExportDirectory] = useState(() => window.localStorage.getItem(CLIP_EXPORT_DIRECTORY_KEY) || '');
@@ -465,9 +473,16 @@ export default function AIPanel() {
const createEditPlan = useCallback(async () => {
const instruction = editPlanInstruction.trim();
if (words.length === 0 || !instruction) return;
+ setAIActionError('');
setProcessing(true, 'Ищу фрагменты по теме...');
try {
const config = providers[defaultProvider];
+ if (['openai', 'claude', 'xai'].includes(defaultProvider) && !config.apiKey?.trim()) {
+ throw new Error(
+ `${AI_PROVIDER_LABELS[defaultProvider]} выбран как активный провайдер, но API-ключ пуст. ` +
+ 'Откройте Settings, вставьте ключ и нажмите Test connection.',
+ );
+ }
const data = await startAIJob(
'/jobs/ai/edit-plan',
{
@@ -491,7 +506,7 @@ export default function AIPanel() {
setEditPlanResult(data);
} catch (err) {
console.error(err);
- alert(`Не удалось найти фрагменты по теме.\n\n${err instanceof Error ? err.message : String(err)}`);
+ setAIActionError(err instanceof Error ? err.message : String(err));
} finally {
setProcessing(false);
}
@@ -1328,6 +1343,36 @@ export default function AIPanel() {
Опишите тему — приложение найдёт все связанные фрагменты даже в длинном стриме.
Сначала проверьте найденные эпизоды и предложенные удаления, затем примените монтаж.
+
+
+ Активный AI: {AI_PROVIDER_LABELS[defaultProvider]}
+
+
+ Модель: {providers[defaultProvider].model || 'не выбрана'}. Для этого запроса используется только этот провайдер.
+
+
+ {aiActionError && (
+
+
+
+
+
AI-запрос не выполнен
+
{aiActionError}
+
+ Откройте Settings → активный провайдер → Test connection. Повторная транскрибация не нужна.
+
+
+
setAIActionError('')}
+ className="shrink-0 text-editor-text-muted hover:text-editor-text"
+ aria-label="Dismiss AI error"
+ >
+
+
+
+
+ )}
Что оставить в видео
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 633ca30..99b10b1 100644
--- a/frontend/src/components/SettingsPanel.tsx
+++ b/frontend/src/components/SettingsPanel.tsx
@@ -1,12 +1,13 @@
import { useAIStore } from '../store/aiStore';
import { useState, useEffect, useCallback } from 'react';
-import type { AIProvider } from '../types/project';
+import type { AIProvider, AIProviderConnectionCheck } from '../types/project';
import { useEditorStore } from '../store/editorStore';
import { Bot, Cloud, Brain, Sparkles, RefreshCw, Route, ShieldCheck, Copy, CheckCircle2, AlertCircle, Download, ExternalLink, MonitorCheck } from 'lucide-react';
import { RELEASE_LINKS, SCRIPTCUT_VERSION } from '../utils/releaseInfo';
import { buildSupportReport } from '../utils/supportReport';
const AI_PROVIDERS: AIProvider[] = ['ollama', 'openai', 'claude', 'xai', '9router'];
+type TestableCloudProvider = 'openai' | 'xai';
const providerLabels: Record = {
ollama: 'Ollama',
@@ -17,7 +18,14 @@ const providerLabels: Record = {
};
export default function SettingsPanel() {
- const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
+ const {
+ providers,
+ providerChecks,
+ defaultProvider,
+ setProviderConfig,
+ setProviderCheck,
+ setDefaultProvider,
+ } = useAIStore();
const { backendUrl } = useEditorStore();
const [ollamaModels, setOllamaModels] = useState([]);
const [nineRouterModels, setNineRouterModels] = useState([]);
@@ -25,6 +33,7 @@ export default function SettingsPanel() {
const [loadingNineRouterModels, setLoadingNineRouterModels] = useState(false);
const [ollamaStatus, setOllamaStatus] = useState<{ ok: boolean; message: string } | null>(null);
const [nineRouterStatus, setNineRouterStatus] = useState<{ ok: boolean; message: string } | null>(null);
+ const [checkingCloudProvider, setCheckingCloudProvider] = useState(null);
const [copiedCommand, setCopiedCommand] = useState('');
const [supportReportStatus, setSupportReportStatus] = useState<'idle' | 'copying' | 'copied' | 'error'>('idle');
@@ -103,6 +112,47 @@ export default function SettingsPanel() {
fetchNineRouterModels();
}, [fetchNineRouterModels]);
+ const checkCloudProvider = useCallback(async (provider: TestableCloudProvider) => {
+ const config = providers[provider];
+ setCheckingCloudProvider(provider);
+ try {
+ const response = await fetch(`${backendUrl}/ai/provider-check`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ provider,
+ api_key: config.apiKey || undefined,
+ model: config.model || undefined,
+ base_url: config.baseUrl || undefined,
+ }),
+ });
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null);
+ throw new Error(errorData?.detail || `Connection test failed: ${response.statusText}`);
+ }
+ const result = (await response.json()) as AIProviderConnectionCheck;
+ setProviderCheck(provider, result);
+ } catch (error) {
+ setProviderCheck(provider, {
+ ok: false,
+ authenticated: false,
+ provider,
+ code: 'local_backend_error',
+ message: error instanceof Error ? error.message : 'Could not run the connection test.',
+ models: [],
+ });
+ } finally {
+ setCheckingCloudProvider(null);
+ }
+ }, [backendUrl, providers, setProviderCheck]);
+
+ const updateCloudConfig = (
+ provider: TestableCloudProvider,
+ config: Parameters[1],
+ ) => {
+ setProviderConfig(provider, config);
+ };
+
const providerIcons: Record = {
ollama: ,
openai: ,
@@ -118,8 +168,14 @@ export default function SettingsPanel() {
local: true,
},
openai: {
- ok: !!providers.openai.apiKey,
- label: providers.openai.apiKey ? 'Key saved' : 'Needs key',
+ ok: !!providerChecks.openai?.ok,
+ label: providerChecks.openai?.ok
+ ? 'Connection verified'
+ : providerChecks.openai
+ ? 'Connection failed'
+ : providers.openai.apiKey
+ ? 'Key not tested'
+ : 'Needs key',
local: false,
},
claude: {
@@ -128,8 +184,14 @@ export default function SettingsPanel() {
local: false,
},
xai: {
- ok: !!providers.xai.apiKey,
- label: providers.xai.apiKey ? 'Key saved' : 'Needs key',
+ ok: !!providerChecks.xai?.ok,
+ label: providerChecks.xai?.ok
+ ? 'Connection verified'
+ : providerChecks.xai
+ ? 'Connection failed'
+ : providers.xai.apiKey
+ ? 'Key not tested'
+ : 'Needs key',
local: false,
},
'9router': {
@@ -284,7 +346,7 @@ export default function SettingsPanel() {
{activeStatus.local
? 'AI transcript actions stay on your configured local endpoint when this provider is selected.'
- : 'AI transcript actions send transcript text to the selected cloud provider when this provider is selected.'}
+ : `AI Editor will use only ${providerLabels[defaultProvider]}. Keys saved under other providers are not used until you select them here.`}
@@ -358,19 +420,32 @@ export default function SettingsPanel() {
{/* OpenAI settings */}
AI actions send transcript text to OpenAI when this provider is selected.
+ setDefaultProvider('openai')}
+ onTest={() => void checkCloudProvider('openai')}
+ />
setProviderConfig('openai', { apiKey: v })}
+ onChange={(v) => updateCloudConfig('openai', { apiKey: v })}
placeholder="sk-..."
type="password"
/>
setProviderConfig('openai', { model: v })}
+ onChange={(v) => updateCloudConfig('openai', { model: v })}
placeholder="gpt-4o"
+ suggestions={providerChecks.openai?.models}
/>
+
+ ChatGPT Plus/Pro and OpenAI API billing are separate. The connection test sends no transcript and uses no completion tokens.
+
{/* Claude settings */}
@@ -393,19 +468,32 @@ export default function SettingsPanel() {
AI actions send transcript text to the official xAI API when this provider is selected.
+ setDefaultProvider('xai')}
+ onTest={() => void checkCloudProvider('xai')}
+ />
setProviderConfig('xai', { apiKey: v })}
+ onChange={(v) => updateCloudConfig('xai', { apiKey: v })}
placeholder="xai-..."
type="password"
/>
setProviderConfig('xai', { model: v })}
+ onChange={(v) => updateCloudConfig('xai', { model: v })}
placeholder="grok-4.5"
+ suggestions={providerChecks.xai?.models}
/>
+
+ The xAI key must allow the Models and Chat endpoints plus the selected model. The connection test sends no transcript.
+
{/* 9router settings */}
@@ -485,6 +573,73 @@ function ProviderNote({ children }: { children: React.ReactNode }) {
return {children}
;
}
+function CloudConnectionControls({
+ active,
+ loading,
+ status,
+ keyUrl,
+ providerLabel,
+ onActivate,
+ onTest,
+}: {
+ active: boolean;
+ loading: boolean;
+ status?: AIProviderConnectionCheck;
+ keyUrl: string;
+ providerLabel: string;
+ onActivate: () => void;
+ onTest: () => void;
+}) {
+ return (
+
+
+
+ {active ? `Active for AI Editor: ${providerLabel}` : `Use ${providerLabel} in AI Editor`}
+
+
+
+ {loading ? 'Testing' : 'Test connection'}
+
+
+ {status && (
+
+ {status.message}
+
+ )}
+
+
+ Create or manage API key
+
+
+ );
+}
+
function SetupCommands({
commands,
copiedCommand,
@@ -548,13 +703,18 @@ function InputField({
onChange,
placeholder,
type = 'text',
+ suggestions,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: string;
+ suggestions?: string[];
}) {
+ const listId = suggestions?.length
+ ? `models-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${placeholder.replace(/[^a-z0-9]+/gi, '-')}`
+ : undefined;
return (
{label && {label} }
@@ -563,8 +723,14 @@ function InputField({
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
+ list={listId}
className="w-full px-3 py-2 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text placeholder:text-editor-text-muted/50 focus:outline-none focus:border-editor-accent"
/>
+ {listId && (
+
+ {suggestions?.map((suggestion) => )}
+
+ )}
);
}
diff --git a/frontend/src/store/aiStore.ts b/frontend/src/store/aiStore.ts
index baf96b3..0277d09 100644
--- a/frontend/src/store/aiStore.ts
+++ b/frontend/src/store/aiStore.ts
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
-import type { AIProvider, AIProviderConfig, CensorMode, FillerWordResult, ClipSuggestion, ClipDraft, ProjectAIWorkspace, FillerReviewDecision, EditPlanResult, EditPlanReviewDecision } from '../types/project';
+import type { AIProvider, AIProviderConfig, AIProviderConnectionCheck, CensorMode, FillerWordResult, ClipSuggestion, ClipDraft, ProjectAIWorkspace, FillerReviewDecision, EditPlanResult, EditPlanReviewDecision } from '../types/project';
const ENCRYPTED_KEY_PREFIX = 'scriptcut_enc_';
const LEGACY_ENCRYPTED_KEY_PREFIX = 'aive_enc_';
@@ -25,6 +25,7 @@ const DEFAULT_PROVIDERS: Record = {
interface AIState {
providers: Record;
+ providerChecks: Partial>;
defaultProvider: AIProvider;
customFillerWords: string;
customCensorWords: string;
@@ -44,6 +45,7 @@ interface AIState {
interface AIActions {
setProviderConfig: (provider: AIProvider, config: Partial) => void;
+ setProviderCheck: (provider: AIProvider, check?: AIProviderConnectionCheck) => void;
setDefaultProvider: (provider: AIProvider) => void;
setCustomFillerWords: (words: string) => void;
setCustomCensorWords: (words: string) => void;
@@ -105,6 +107,7 @@ export const useAIStore = create()(
persist(
(set, get) => ({
providers: DEFAULT_PROVIDERS,
+ providerChecks: {},
defaultProvider: 'ollama',
customFillerWords: '',
customCensorWords: '',
@@ -127,6 +130,10 @@ export const useAIStore = create()(
...state.providers,
[provider]: { ...state.providers[provider], ...config },
},
+ providerChecks: {
+ ...state.providerChecks,
+ [provider]: undefined,
+ },
}));
if (config.apiKey !== undefined) {
@@ -134,6 +141,14 @@ export const useAIStore = create()(
}
},
+ setProviderCheck: (provider, check) =>
+ set((state) => ({
+ providerChecks: {
+ ...state.providerChecks,
+ [provider]: check,
+ },
+ })),
+
setDefaultProvider: (provider) => set({ defaultProvider: provider }),
setCustomFillerWords: (words) => set({ customFillerWords: words }),
diff --git a/frontend/src/types/project.ts b/frontend/src/types/project.ts
index 8c75c7c..d6dc6a7 100644
--- a/frontend/src/types/project.ts
+++ b/frontend/src/types/project.ts
@@ -127,6 +127,16 @@ export interface AIProviderConfig {
model: string;
}
+export interface AIProviderConnectionCheck {
+ ok: boolean;
+ authenticated: boolean;
+ provider: string;
+ code: string;
+ message: string;
+ models: string[];
+ model_available?: boolean | null;
+}
+
export interface FillerWordResult {
wordIndices: number[];
fillerWords: Array<{ index: number; word: string; reason: string; confidence?: number }>;
diff --git a/frontend/src/utils/releaseInfo.ts b/frontend/src/utils/releaseInfo.ts
index 7217604..ef48f0f 100644
--- a/frontend/src/utils/releaseInfo.ts
+++ b/frontend/src/utils/releaseInfo.ts
@@ -1,13 +1,13 @@
-export const SCRIPTCUT_VERSION = '0.1.0-alpha.2';
+export const SCRIPTCUT_VERSION = '0.1.2';
export const RELEASE_LINKS = {
- latestRelease: 'https://github.com/FernandoAbishai/ScriptCut/releases/latest',
- releases: 'https://github.com/FernandoAbishai/ScriptCut/releases',
- issues: 'https://github.com/FernandoAbishai/ScriptCut/issues',
- bugReport: 'https://github.com/FernandoAbishai/ScriptCut/issues/new?template=bug_report.yml',
- installGuide: 'https://github.com/FernandoAbishai/ScriptCut/blob/main/docs/INSTALL.md',
- firstExport: 'https://github.com/FernandoAbishai/ScriptCut/blob/main/docs/FIRST_EXPORT.md',
- platformSupport: 'https://github.com/FernandoAbishai/ScriptCut/blob/main/docs/PLATFORM_SUPPORT.md',
+ latestRelease: 'https://github.com/SmetankaKluss/ScriptCut/releases/latest',
+ releases: 'https://github.com/SmetankaKluss/ScriptCut/releases',
+ issues: 'https://github.com/SmetankaKluss/ScriptCut/issues',
+ bugReport: 'https://github.com/SmetankaKluss/ScriptCut/issues/new?template=bug_report.yml',
+ installGuide: 'https://github.com/SmetankaKluss/ScriptCut/blob/main/docs/INSTALL.md',
+ firstExport: 'https://github.com/SmetankaKluss/ScriptCut/blob/main/docs/FIRST_EXPORT.md',
+ platformSupport: 'https://github.com/SmetankaKluss/ScriptCut/blob/main/docs/PLATFORM_SUPPORT.md',
pythonDownloads: 'https://www.python.org/downloads/macos/',
- troubleshooting: 'https://github.com/FernandoAbishai/ScriptCut/blob/main/docs/TROUBLESHOOTING.md',
+ troubleshooting: 'https://github.com/SmetankaKluss/ScriptCut/blob/main/docs/TROUBLESHOOTING.md',
};
diff --git a/package-lock.json b/package-lock.json
index 8c71d6f..1edb119 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "scriptcut",
- "version": "0.1.1",
+ "version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scriptcut",
- "version": "0.1.1",
+ "version": "0.1.2",
"dependencies": {
"python-shell": "^5.0.0"
},
diff --git a/package.json b/package.json
index 9ca897e..4188e4c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "scriptcut",
- "version": "0.1.1",
+ "version": "0.1.2",
"private": true,
"author": "Fernando Abishai",
"description": "ScriptCut — Open-source AI-powered text-based video editor",
From 3797810706c73648452a9e1caa629b647b7c0462 Mon Sep 17 00:00:00 2001
From: Alice
Date: Wed, 29 Jul 2026 21:49:51 +0800
Subject: [PATCH 2/2] Handle versioned Windows FFmpeg assets
---
scripts/download-windows-ffmpeg.js | 27 +++++++++++++++++++++++----
scripts/smoke-packaging.js | 10 ++++++++++
2 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/scripts/download-windows-ffmpeg.js b/scripts/download-windows-ffmpeg.js
index a8b8c21..9f353df 100644
--- a/scripts/download-windows-ffmpeg.js
+++ b/scripts/download-windows-ffmpeg.js
@@ -21,9 +21,8 @@ const cacheDir = path.join(root, '.cache', 'windows-ffmpeg');
const configuredReleaseBase = process.env.SCRIPTCUT_WINDOWS_FFMPEG_RELEASE || '';
const target = process.arch === 'arm64' ? 'winarm64' : 'win64';
const defaultAsset = `ffmpeg-n8.1-latest-${target}-gpl-8.1.zip`;
-const assetName = process.env.SCRIPTCUT_WINDOWS_FFMPEG_ASSET || defaultAsset;
-const archivePath = path.join(cacheDir, assetName);
-const extractDir = path.join(cacheDir, path.basename(assetName, '.zip'));
+const configuredAssetName = process.env.SCRIPTCUT_WINDOWS_FFMPEG_ASSET || '';
+let assetName = configuredAssetName || defaultAsset;
const releaseResolveAttempts = Number(process.env.SCRIPTCUT_FFMPEG_RELEASE_ATTEMPTS || 6);
function fail(message) {
@@ -57,7 +56,25 @@ async function resolveRelease() {
const release = await (await response(
'https://api.github.com/repos/BtbN/FFmpeg-Builds/releases/latest',
)).json();
- const archive = release?.assets?.find((asset) => asset.name === assetName);
+ let archive = release?.assets?.find((asset) => asset.name === assetName);
+ if (!archive && !configuredAssetName) {
+ const versionedAssetPattern = new RegExp(
+ `^ffmpeg-n8\\.1(?:\\.\\d+)?(?:-\\d+-g[0-9a-f]+)?-${target}-gpl-8\\.1\\.zip$`,
+ 'i',
+ );
+ const versionedAssets = (release?.assets || []).filter((asset) =>
+ versionedAssetPattern.test(asset.name || ''),
+ );
+ if (versionedAssets.length === 1) {
+ archive = versionedAssets[0];
+ assetName = archive.name;
+ } else if (versionedAssets.length > 1) {
+ fail(
+ `GitHub release contains multiple matching FFmpeg 8.1 assets for ${target}: ` +
+ versionedAssets.map((asset) => asset.name).join(', '),
+ );
+ }
+ }
const checksums = release?.assets?.find((asset) => asset.name === 'checksums.sha256');
if (!archive?.url || !checksums?.url) fail(`GitHub release does not contain ${assetName} and checksums.sha256`);
return {
@@ -157,6 +174,8 @@ async function main() {
fs.mkdirSync(cacheDir, { recursive: true });
const { release, expected } = await resolveReleaseAndChecksum();
+ const archivePath = path.join(cacheDir, assetName);
+ const extractDir = path.join(cacheDir, path.basename(assetName, '.zip'));
if (!fs.existsSync(archivePath) || sha256(archivePath) !== expected) {
await download(release.archiveUrl, archivePath, release.downloadHeaders);
diff --git a/scripts/smoke-packaging.js b/scripts/smoke-packaging.js
index 31337e7..805bbf8 100644
--- a/scripts/smoke-packaging.js
+++ b/scripts/smoke-packaging.js
@@ -62,4 +62,14 @@ for (const script of [
assert(fs.existsSync(path.join(root, script)), `Missing ${script}`);
}
+const windowsFfmpegDownloader = read('scripts/download-windows-ffmpeg.js');
+assert(
+ windowsFfmpegDownloader.includes('versionedAssetPattern'),
+ 'Windows FFmpeg resolution must support versioned release asset names',
+);
+assert(
+ windowsFfmpegDownloader.includes('expectedChecksum(checksums)'),
+ 'Versioned Windows FFmpeg downloads must remain checksum verified',
+);
+
console.log('Desktop packaging configuration smoke checks passed.');