diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9bf8e03..11ea8462 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,9 @@ jobs: - name: Internationalization checks run: node --test tests/i18n.test.cjs + - name: Subtitle failure guidance + run: node --test tests/subtitle-failure.test.cjs + - name: Publish UI helpers run: node --test tests/upload-post-ui.test.cjs diff --git a/backend/pipeline/failures.py b/backend/pipeline/failures.py index 0d99c4f3..1ad2e6f4 100644 --- a/backend/pipeline/failures.py +++ b/backend/pipeline/failures.py @@ -16,10 +16,13 @@ class PipelineFailure(RuntimeError): hint: 用户下一步能做什么(去哪个设置项、装什么),追加在错误正文后面。 """ - def __init__(self, stage: str, message: str, hint: str = ""): + def __init__(self, stage: str, message: str, hint: str = "", code: str = ""): super().__init__(message) self.stage = stage self.hint = hint + # 稳定机器码,前端用来打开「设置 → 转写」并换一句对得上的说明。 + # whisper_not_installed | whisper_install_failed | transcription_empty | subtitle_setup + self.code = code @property def message(self) -> str: @@ -33,3 +36,58 @@ HINT_CHECK_LLM = "请到「设置 → 模型」检查提供商、API Key 与模 HINT_SUBTITLE = "到「设置 → 转写」安装 Whisper 模型让 AutoClip 自动转写,或导入 .srt 字幕后重试。" HINT_LOWER_THRESHOLD = "到「设置 → 模型 → 最低评分阈值」调低后重试,或换一个更强的模型。" HINT_CHECK_FFMPEG = "确认 ffmpeg 可用(桌面版内置;Docker / 脚本模式请检查 PATH),以及原视频文件完整可播放。" + + +def missing_subtitle_failure() -> PipelineFailure: + """视频没有字幕,自动转写也没留下 srt。按当前 Whisper 状态区分下一步。""" + from backend.services import whisper_runtime + + status = whisper_runtime.get_status() + state = status.get("status") + if state == "error": + return PipelineFailure( + "SUBTITLE", + "没有字幕可分析:视频不带字幕,且 Whisper 上次安装没有成功。", + "到「设置 → 转写」重试安装,或重新导入时带上 .srt 字幕。", + code="whisper_install_failed", + ) + if state != "installed": + return PipelineFailure( + "SUBTITLE", + "没有字幕可分析:视频不带字幕,且本地 Whisper 还没安装。", + "到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt 字幕。", + code="whisper_not_installed", + ) + return PipelineFailure( + "SUBTITLE", + "没有字幕可分析:视频不带字幕,Whisper 已安装,但这次转写没有生成可用字幕。", + "确认视频里有清晰人声后重试,或重新导入时带上 .srt 字幕。", + code="transcription_empty", + ) + + +def failure_from_speech_error(message: str) -> PipelineFailure: + """转写异常收成同一套失败码,不再把「设置 → 语音识别」和「设置 → 转写」叠在一起。""" + text = (message or "").strip().replace("设置 → 语音识别", "设置 → 转写") + from backend.services import whisper_runtime + + state = whisper_runtime.get_status().get("status") + not_installed = ("未安装" in text) or ("没有可用的语音识别" in text) + if not_installed: + if state == "error": + return missing_subtitle_failure() + return PipelineFailure( + "SUBTITLE", + "没有字幕可分析:视频不带字幕,且本地 Whisper 还没安装。", + "到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt 字幕。", + code="whisper_not_installed", + ) + if any(token in text for token in ("未识别出任何语音", "没有从这段视频", "可用语音")): + return PipelineFailure( + "SUBTITLE", + "没有字幕可分析:视频不带字幕,Whisper 已安装,但这次转写没有生成可用字幕。", + "确认视频里有清晰人声后重试,或重新导入时带上 .srt 字幕。", + code="transcription_empty", + ) + hint = "" if "设置 → 转写" in text else HINT_SUBTITLE + return PipelineFailure("SUBTITLE", text, hint, code="subtitle_setup") diff --git a/backend/schemas/project.py b/backend/schemas/project.py index 4f61aaf5..050b0930 100644 --- a/backend/schemas/project.py +++ b/backend/schemas/project.py @@ -72,6 +72,7 @@ class ProjectResponse(BaseSchema): # 失败态:最近一次失败任务的错误文本(带阶段与下一步提示),详情页 / 项目卡 / 应用内反馈直接展示 error_message: Optional[str] = Field(default=None, description="Latest failure message when status is failed") + error_code: Optional[str] = Field(default=None, description="Stable failure code, e.g. whisper_not_installed") # Statistics total_clips: int = Field(default=0, description="Total number of clips") diff --git a/backend/services/local_runner.py b/backend/services/local_runner.py index cc08ee47..a5d7b5b6 100644 --- a/backend/services/local_runner.py +++ b/backend/services/local_runner.py @@ -254,7 +254,7 @@ def _register_project(req: RunRequest, video_path: Path) -> None: db.close() -def _set_project_status(project_id: str, status: str, error: Optional[str] = None) -> None: +def _set_project_status(project_id: str, status: str, error: Optional[str] = None, error_code: Optional[str] = None) -> None: try: from backend.core.database import SessionLocal from backend.models.project import Project, ProjectStatus @@ -273,6 +273,8 @@ def _set_project_status(project_id: str, status: str, error: Optional[str] = Non # ProjectService.latest_error_message 会回退读它,桌面首页 / 详情页照样能看到原因 meta = dict(p.project_metadata or {}) meta["last_error"] = error[:2000] + if error_code: + meta["last_error_code"] = error_code p.project_metadata = meta db.commit() finally: @@ -352,7 +354,7 @@ def run_pipeline(req: RunRequest, video_in_raw: Path, on_progress: Optional[Prog if result.get("status") == "succeeded": _set_project_status(req.project_id, "completed") else: - _set_project_status(req.project_id, "failed", result.get("error")) + _set_project_status(req.project_id, "failed", result.get("error"), result.get("error_code")) return result diff --git a/backend/services/project_service.py b/backend/services/project_service.py index 9466a7e8..98832dfa 100644 --- a/backend/services/project_service.py +++ b/backend/services/project_service.py @@ -68,13 +68,13 @@ class ProjectService(BaseService[Project, ProjectCreate, ProjectUpdate, ProjectR return self.update(project_id, **orm_data) - def latest_error_message(self, project, status=None) -> Optional[str]: - """项目失败时的错误文本:最近一条任务的 Task.error_message,其次 project_metadata.last_error(CLI 路径)。 - Project 表没有 error_message 列。""" + def latest_failure(self, project, status=None): + """失败文本和稳定错误码。文本来自最近一条任务,其次 project_metadata.last_error(CLI 路径)。 + 错误码来自任务 result_data.error_code,其次 metadata.last_error_code。""" status = status if status is not None else getattr(project, 'status', None) status_value = getattr(status, "value", status) if str(status_value).lower() != "failed": - return None + return None, None from ..models.task import Task project_id = str(getattr(project, 'id', '')) task = ( @@ -84,9 +84,20 @@ class ProjectService(BaseService[Project, ProjectCreate, ProjectUpdate, ProjectR .first() ) if task and task.error_message: - return task.error_message + data = getattr(task, "result_data", None) + code = data.get("error_code") if isinstance(data, dict) else None + return task.error_message, code or None meta = getattr(project, 'project_metadata', None) or {} - return meta.get("last_error") or None + return meta.get("last_error") or None, meta.get("last_error_code") or None + + def latest_error_message(self, project, status=None) -> Optional[str]: + """项目失败时的错误文本。Project 表没有 error_message 列。""" + message, _code = self.latest_failure(project, status) + return message + + def latest_error_code(self, project, status=None) -> Optional[str]: + _message, code = self.latest_failure(project, status) + return code def get_project_with_stats(self, project_id: str) -> Optional[ProjectResponse]: """Get project with statistics.""" @@ -104,8 +115,10 @@ class ProjectService(BaseService[Project, ProjectCreate, ProjectUpdate, ProjectR total_tasks = self.db.query(Task).filter(Task.project_id == project_id).count() # Convert to response schema + error_message, error_code = self.latest_failure(project) return ProjectResponse( - error_message=self.latest_error_message(project), + error_message=error_message, + error_code=error_code, id=str(getattr(project, 'id', '')), name=str(getattr(project, 'name', '')), description=str(getattr(project, 'description', '')) if getattr(project, 'description', None) is not None else None, @@ -151,8 +164,10 @@ class ProjectService(BaseService[Project, ProjectCreate, ProjectUpdate, ProjectR total_collections = self.db.query(Collection).filter(Collection.project_id == project_id).count() total_tasks = self.db.query(Task).filter(Task.project_id == project_id).count() + error_message, error_code = self.latest_failure(project) project_responses.append(ProjectResponse( - error_message=self.latest_error_message(project), + error_message=error_message, + error_code=error_code, id=str(getattr(project, 'id', '')), name=str(getattr(project, 'name', '')), description=str(getattr(project, 'description', '')) if getattr(project, 'description', None) is not None else None, diff --git a/backend/services/simple_pipeline_adapter.py b/backend/services/simple_pipeline_adapter.py index be1f84f0..3a1e553e 100644 --- a/backend/services/simple_pipeline_adapter.py +++ b/backend/services/simple_pipeline_adapter.py @@ -9,7 +9,8 @@ from pathlib import Path from backend.services.simple_progress import emit_progress, clear_progress from backend.pipeline.failures import ( - PipelineFailure, HINT_CHECK_LLM, HINT_SUBTITLE, HINT_LOWER_THRESHOLD, HINT_CHECK_FFMPEG, + PipelineFailure, HINT_CHECK_LLM, HINT_LOWER_THRESHOLD, HINT_CHECK_FFMPEG, + failure_from_speech_error, missing_subtitle_failure, ) from backend.pipeline.step1_outline import run_step1_outline from backend.pipeline.step2_timeline import run_step2_timeline @@ -183,14 +184,10 @@ class SimplePipelineAdapter: try: srt_path = await self._generate_subtitle_automatically(input_video_path, metadata_dir) except SpeechRecognitionError as e: - raise PipelineFailure("SUBTITLE", str(e), HINT_SUBTITLE) from e + raise failure_from_speech_error(str(e)) from e if not (srt_path and srt_path.exists()): # 以前这里写一个空大纲然后一路「成功」到底,用户看到的是 Completed · 0 切片 - raise PipelineFailure( - "SUBTITLE", - "没有字幕可分析:视频不带字幕,且本地转写没有生成结果。", - HINT_SUBTITLE, - ) + raise missing_subtitle_failure() logger.info(f"自动生成字幕成功: {srt_path}") # Step 1: 大纲提取(字幕为空 / 模型全部失败 / 无法解析时由 step1 自己抛 PipelineFailure) @@ -316,7 +313,7 @@ class SimplePipelineAdapter: error_msg = e.user_message() logger.error(f"流水线在 {e.stage} 阶段失败: {error_msg}") emit_progress(self.project_id, e.stage, f"处理失败:{error_msg}") - return { + failed = { "status": "failed", "project_id": self.project_id, "task_id": self.task_id, @@ -324,6 +321,9 @@ class SimplePipelineAdapter: "error": error_msg, "message": error_msg, } + if e.code: + failed["error_code"] = e.code + return failed except Exception as e: error_msg = f"流水线处理失败: {str(e)}" logger.exception(error_msg) diff --git a/backend/services/whisper_runtime.py b/backend/services/whisper_runtime.py index ba8b69ce..724aae75 100644 --- a/backend/services/whisper_runtime.py +++ b/backend/services/whisper_runtime.py @@ -52,6 +52,34 @@ def get_models_dir() -> Path: return d +def _install_error_path() -> Path: + return _data_dir() / "whisper-install-error.txt" + + +def _read_install_error() -> str: + """上次安装失败的一句说明。进程重启后内存状态会丢,设置页仍要能显示「重试安装」。""" + try: + return _install_error_path().read_text(encoding="utf-8").strip()[:500] + except Exception: + return "" + + +def _write_install_error(message: str) -> None: + try: + path = _install_error_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text((message or "安装失败")[:500], encoding="utf-8") + except Exception as e: # noqa: BLE001 + logger.warning(f"记录 Whisper 安装失败原因失败: {e}") + + +def _clear_install_error() -> None: + try: + _install_error_path().unlink(missing_ok=True) + except Exception: + pass + + def ensure_on_path() -> None: """把运行时目录加入 sys.path,并把模型缓存目录收口到 HF_HOME。""" install_dir = str(get_install_dir()) @@ -98,11 +126,21 @@ def _set_state(**kw) -> None: def get_status() -> Dict[str, Any]: with _state_lock: st = dict(_state) - # 没在安装时,用实际探测结果覆盖 - if st["status"] not in ("installing",): - st["status"] = "installed" if is_installed() else "not_installed" - if st["status"] == "installed": + # 安装进行中不打断。装完后以能否导入为准。 + # 失败必须留下 error:否则每次轮询都盖成 not_installed,设置页的「重试安装」永远不会出现。 + if st["status"] != "installing": + if is_installed(): + st["status"] = "installed" st["progress"] = 100 + elif st["status"] == "error": + pass + else: + remembered = _read_install_error() + if remembered: + st["status"] = "error" + st["message"] = remembered + else: + st["status"] = "not_installed" st["platform_supported"] = True # faster-whisper 跨平台 st["packages"] = WHISPER_PACKAGES return st @@ -141,14 +179,19 @@ def _do_install(index_url: Optional[str]) -> None: _set_state(log_tail="\n".join(lines[-12:])) proc.wait() if proc.returncode == 0 and is_installed(): + _clear_install_error() _set_state(status="installed", progress=100, message="安装完成") logger.info("Whisper 运行时安装完成") else: - _set_state(status="error", message=f"安装失败(pip 退出码 {proc.returncode})") + message = f"安装失败(pip 退出码 {proc.returncode})" + _write_install_error(message) + _set_state(status="error", message=message) logger.error(f"Whisper 运行时安装失败,pip 退出码 {proc.returncode}") except Exception as e: # noqa: BLE001 logger.error(f"安装 Whisper 运行时异常: {e}", exc_info=True) - _set_state(status="error", message=f"安装异常: {e}") + message = f"安装异常: {e}" + _write_install_error(message) + _set_state(status="error", message=message) def _bump_progress(min_v: int, max_v: int, message: str) -> None: @@ -184,6 +227,7 @@ def uninstall() -> Dict[str, Any]: p = str(install_dir) if p in sys.path: sys.path.remove(p) + _clear_install_error() _set_state(status="not_installed", progress=0, message="已卸载") return {"success": True, "message": "已卸载 Whisper 运行时"} except Exception as e: # noqa: BLE001 diff --git a/backend/tests/test_pipeline_failures.py b/backend/tests/test_pipeline_failures.py index 7ac72345..cd74bc57 100644 --- a/backend/tests/test_pipeline_failures.py +++ b/backend/tests/test_pipeline_failures.py @@ -148,8 +148,16 @@ def test_adapter_fails_fast_when_llm_not_configured(adapter, monkeypatch, tmp_pa assert adapter._events[-1][1].startswith("处理失败:") +def _whisper_status(monkeypatch, status: str, message: str = ""): + monkeypatch.setattr( + "backend.services.whisper_runtime.get_status", + lambda: {"status": status, "message": message}, + ) + + def test_adapter_fails_when_no_subtitle_and_auto_transcribe_yields_nothing(adapter, monkeypatch, tmp_path): _fake_manager(monkeypatch, available=True) + _whisper_status(monkeypatch, "not_installed") async def no_srt(self, video, metadata_dir): return None @@ -160,12 +168,35 @@ def test_adapter_fails_when_no_subtitle_and_auto_transcribe_yields_nothing(adapt assert result["status"] == "failed" assert result["stage"] == "SUBTITLE" - assert "没有字幕" in result["error"] - assert "Whisper" in result["error"] + assert result["error_code"] == "whisper_not_installed" + assert "还没安装" in result["error"] + assert "设置 → 转写" in result["error"] + assert ".srt" in result["error"] # 以前会写一个空大纲然后「成功」;现在不该再产出 step1_outline.json assert not (tmp_path / "projects" / "proj-1" / "metadata" / "step1_outline.json").exists() +def test_adapter_distinguishes_install_failure_from_empty_transcript(adapter, monkeypatch, tmp_path): + _fake_manager(monkeypatch, available=True) + + async def no_srt(self, video, metadata_dir): + return None + + monkeypatch.setattr(type(adapter), "_generate_subtitle_automatically", no_srt) + + _whisper_status(monkeypatch, "error", "安装失败(pip 退出码 1)") + failed = asyncio.run(adapter.process_project_sync(str(tmp_path / "in.mp4"), "")) + assert failed["error_code"] == "whisper_install_failed" + assert "上次安装没有成功" in failed["error"] + assert ".srt" in failed["error"] + + _whisper_status(monkeypatch, "installed") + empty = asyncio.run(adapter.process_project_sync(str(tmp_path / "in.mp4"), "")) + assert empty["error_code"] == "transcription_empty" + assert "已安装" in empty["error"] + assert ".srt" in empty["error"] + + def test_adapter_surfaces_whisper_error_on_the_subtitle_stage(adapter, monkeypatch, tmp_path): """转写失败要把可读原因带回失败态,而不是只留「没有字幕」。""" from backend.utils.speech_recognizer import SpeechRecognitionError @@ -183,6 +214,7 @@ def test_adapter_surfaces_whisper_error_on_the_subtitle_stage(adapter, monkeypat assert result["status"] == "failed" assert result["stage"] == "SUBTITLE" + assert result["error_code"] == "subtitle_setup" assert "显卡" in result["error"] assert "设置 → 转写" in result["error"] assert result["message"] == result["error"] @@ -305,6 +337,35 @@ def test_project_response_falls_back_to_metadata_for_cli_runs(): project_metadata={"last_error": "没有可用的 LLM 提供商"}) assert svc.latest_error_message(project) == "没有可用的 LLM 提供商" + assert svc.latest_error_code(project) is None + + +def test_project_response_exposes_subtitle_error_code(): + from backend.services.project_service import ProjectService + + class Q: + def __init__(self, row): self.row = row + def filter(self, *a, **k): return self + def order_by(self, *a, **k): return self + def first(self): return self.row + + task = SimpleNamespace( + error_message="没有字幕可分析", + result_data={"error_code": "whisper_not_installed"}, + ) + svc = ProjectService.__new__(ProjectService) + svc.db = SimpleNamespace(query=lambda model: Q(task)) + project = SimpleNamespace(id="p1", status="failed", project_metadata={}) + + assert svc.latest_error_code(project) == "whisper_not_installed" + + svc.db = SimpleNamespace(query=lambda model: Q(None)) + cli_project = SimpleNamespace( + id="p1", + status="failed", + project_metadata={"last_error": "没有字幕", "last_error_code": "transcription_empty"}, + ) + assert svc.latest_error_code(cli_project) == "transcription_empty" def test_processing_task_reads_error_field_from_adapter_result(): diff --git a/backend/tests/test_whisper_local_subtitle.py b/backend/tests/test_whisper_local_subtitle.py index 4ba647b4..aad996fd 100644 --- a/backend/tests/test_whisper_local_subtitle.py +++ b/backend/tests/test_whisper_local_subtitle.py @@ -51,6 +51,53 @@ def test_vad_failure_is_classified_without_treating_encode_as_vad(): assert vad_backend_failed(exc.value) +def test_install_error_is_not_reported_as_not_installed(monkeypatch): + """设置页靠 status=error 显示「重试安装」。轮询不能把它盖成 not_installed。""" + monkeypatch.setattr(whisper_runtime, "is_installed", lambda: False) + whisper_runtime._set_state(status="error", progress=12, message="安装失败(pip 退出码 1)", log_tail="boom") + try: + status = whisper_runtime.get_status() + assert status["status"] == "error" + assert "退出码" in status["message"] + finally: + whisper_runtime._set_state(status="unknown", progress=0, message="", log_tail="") + + +def test_installed_runtime_still_reports_installed_after_a_previous_error(monkeypatch): + monkeypatch.setattr(whisper_runtime, "is_installed", lambda: True) + whisper_runtime._set_state(status="error", progress=12, message="安装失败", log_tail="") + try: + assert whisper_runtime.get_status()["status"] == "installed" + finally: + whisper_runtime._set_state(status="unknown", progress=0, message="", log_tail="") + + +def test_install_error_is_remembered_after_the_process_forgets(tmp_path, monkeypatch): + monkeypatch.setattr(whisper_runtime, "_data_dir", lambda: tmp_path) + monkeypatch.setattr(whisper_runtime, "is_installed", lambda: False) + whisper_runtime._set_state(status="unknown", progress=0, message="", log_tail="") + whisper_runtime._write_install_error("安装失败(pip 退出码 1)") + try: + status = whisper_runtime.get_status() + assert status["status"] == "error" + assert "退出码" in status["message"] + finally: + whisper_runtime._clear_install_error() + whisper_runtime._set_state(status="unknown", progress=0, message="", log_tail="") + + +def test_missing_runtime_points_at_transcription_settings(tmp_path, monkeypatch): + monkeypatch.setattr(whisper_runtime, "is_installed", lambda: False) + recognizer = _recognizer() + video = tmp_path / "clip.mp4" + video.write_bytes(b"x") + with pytest.raises(SpeechRecognitionError) as exc: + recognizer._generate_subtitle_whisper_local(video, tmp_path / "clip.srt", SpeechRecognitionConfig()) + assert "设置 → 转写" in str(exc.value) + assert "语音识别" not in str(exc.value) + assert ".srt" in str(exc.value) + + def test_failure_text_has_no_local_path(): message = describe_whisper_failure(RuntimeError(r"failed to open C:\Users\secret\clip.mp4")) assert "secret" not in message diff --git a/backend/utils/speech_recognizer.py b/backend/utils/speech_recognizer.py index 57b128ac..106bda39 100644 --- a/backend/utils/speech_recognizer.py +++ b/backend/utils/speech_recognizer.py @@ -410,8 +410,8 @@ class SpeechRecognizer: if not whisper_runtime.is_installed(): raise SpeechRecognitionError( - "本地 Whisper 运行时未安装。请到「设置 → 语音识别」里点击安装 Whisper," - "并下载一个模型后再试。" + "本地 Whisper 运行时未安装。请到「设置 → 转写」安装 Whisper," + "并下载一个模型后再试。也可以重新导入时带上 .srt 字幕。" ) if not video_path.exists(): @@ -492,7 +492,7 @@ class SpeechRecognizer: raise except ModuleNotFoundError as e: raise SpeechRecognitionError( - f"Whisper 运行时缺少依赖({e})。请到「设置 → 语音识别」重新安装 Whisper。" + f"Whisper 运行时缺少依赖({e})。请到「设置 → 转写」重新安装 Whisper。" ) except Exception as e: # noqa: BLE001 # 不带 exc_info:LoggingIntegration 会把带堆栈的 error 记成 Sentry 异常。 diff --git a/docs/CLI_AND_MCP.md b/docs/CLI_AND_MCP.md index 4bc206ad..8eea9887 100644 --- a/docs/CLI_AND_MCP.md +++ b/docs/CLI_AND_MCP.md @@ -25,7 +25,7 @@ pip install -e . # 得到 autoclip / autoclip-mcp 两个命令 不装包也能用:`python -m backend.cli ...`(在仓库根目录)。 需要 ffmpeg 在 PATH(`brew install ffmpeg`)。没有字幕的视频要本地 Whisper:`pip install faster-whisper`, -或在桌面应用「设置 → 语音识别」一键安装(两者共用模型目录)。 +或在桌面应用「设置 → 转写」一键安装(两者共用模型目录)。 先体检: diff --git a/frontend/src/components/ProjectCard.tsx b/frontend/src/components/ProjectCard.tsx index 6bdc708e..28d8d940 100644 --- a/frontend/src/components/ProjectCard.tsx +++ b/frontend/src/components/ProjectCard.tsx @@ -10,6 +10,7 @@ import { UnifiedStatusBar } from './UnifiedStatusBar' import FeedbackDialog from './FeedbackDialog' import { useSimpleProgressStore } from '../stores/useSimpleProgressStore' import { Btn } from '../ui' +import { classifySubtitleFailure } from '../utils/subtitleFailure' import { isSourceDownloading, readDownloadProgress } from '../utils/downloadProgress' // import { // getProjectStatusConfig, @@ -292,6 +293,10 @@ const ProjectCard: React.FC = ({ project, onDelete, onRetry, o stage: failedProgress?.stage, error_message: project.error_message || failedProgress?.message || undefined, } + const subtitleFailure = classifySubtitleFailure( + project.error_message || failedProgress?.message, + project.error_code, + ) const handleRetry = async (opts?: { silent?: boolean }) => { if (isRetrying) return @@ -621,7 +626,7 @@ const ProjectCard: React.FC = ({ project, onDelete, onRetry, o {/* 状态和统计信息 — Calm Premium,见 DESIGN.md */} {(normalizedStatus === 'importing' || normalizedStatus === 'downloading' || normalizedStatus === 'processing' || normalizedStatus === 'failed') ? ( // 进行中 / 失败:细进度线或终态点,占满宽度 -
+
= ({ project, onDelete, onRetry, o /> {normalizedStatus === 'failed' && ( // 失败态:重试 + 反馈(反馈自动带上阶段 / 错误 / 版本 / 模型上下文) -
e.stopPropagation()}> +
e.stopPropagation()}> + {subtitleFailure && ( + navigate('/settings?section=speech')}>{t("转写设置")} + )} handleRetry()}>{t("重试")} setFeedbackOpen(true)}>{t("反馈")}
diff --git a/frontend/src/components/SubtitleFailureEmpty.tsx b/frontend/src/components/SubtitleFailureEmpty.tsx new file mode 100644 index 00000000..d3b72e73 --- /dev/null +++ b/frontend/src/components/SubtitleFailureEmpty.tsx @@ -0,0 +1,59 @@ +import { t } from '../i18n' +import React from 'react' +import { Btn } from '../ui' +import { SubtitleFailureKind } from '../utils/subtitleFailure' + +function copyFor(kind: SubtitleFailureKind): { title: string; body: string } { + switch (kind) { + case 'whisper_not_installed': + return { + title: t("还没有安装本地转写"), + body: t("这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。"), + } + case 'whisper_install_failed': + return { + title: t("Whisper 上次没有装上"), + body: t("这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。"), + } + case 'transcription_empty': + return { + title: t("转写没有得到字幕"), + body: t("Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。"), + } + case 'subtitle_unknown': + return { + title: t("没有字幕可分析"), + body: t("这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。"), + } + default: + return { + title: t("这次处理没有成功"), + body: t("到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。"), + } + } +} + +/** 失败空态:一句原因、一句可做的事、打开「设置 → 转写」的主按钮。 */ +const SubtitleFailureEmpty: React.FC<{ + kind: SubtitleFailureKind + errorMessage?: string + onOpenSettings: () => void +}> = ({ kind, errorMessage, onOpenSettings }) => { + const copy = copyFor(kind) + return ( +
+ {copy.title} + {copy.body} + {errorMessage && ( + + {errorMessage} + + )} +
+ {t("打开转写设置")} +
+
+ ) +} + +export default SubtitleFailureEmpty diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 43476221..7f697049 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -777,5 +777,16 @@ "操作教程": "How-to", "海外": "Overseas", "贴三个 Cookie 后保存。配好后,发布页会出现 B 站。": "Paste the three cookies and save. Bilibili then appears on the publish page.", - "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Add the Upload-Post key here. After that, pick it on the clip’s publish page." + "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Add the Upload-Post key here. After that, pick it on the clip’s publish page.", + "还没有安装本地转写": "Local transcription isn’t installed", + "这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。": "This video has no captions. Install Whisper and download a model in Settings → Transcription, or import it again with an .srt file.", + "Whisper 上次没有装上": "Whisper didn’t finish installing", + "这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。": "This video has no captions, and the last Whisper install didn’t succeed. Retry the install in Settings → Transcription, or import it again with an .srt file.", + "转写没有得到字幕": "Transcription produced no captions", + "Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。": "Whisper is installed, but this video produced no usable speech. Check that it has clear speech and retry, or import it again with an .srt file.", + "没有字幕可分析": "No captions to analyze", + "这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。": "This video has no captions, and local transcription produced nothing. Install or retry Whisper in Settings → Transcription, or import it again with an .srt file.", + "到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。": "In Settings → Transcription, check that Whisper is installed and a model is downloaded, or import it again with an .srt file.", + "打开转写设置": "Open transcription settings", + "转写设置": "Transcription" } diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index fdf71db1..27d1876a 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -777,5 +777,16 @@ "操作教程": "Tutorial", "海外": "Internacional", "贴三个 Cookie 后保存。配好后,发布页会出现 B 站。": "Pega las tres cookies y guarda. Bilibili aparecerá en la página de publicación.", - "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Pon aquí la clave de Upload-Post. Luego la eliges en la página de publicación del clip." + "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Pon aquí la clave de Upload-Post. Luego la eliges en la página de publicación del clip.", + "还没有安装本地转写": "La transcripción local no está instalada", + "这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。": "Este vídeo no tiene subtítulos. Instala Whisper y descarga un modelo en Ajustes → Transcripción, o vuelve a importarlo con un archivo .srt.", + "Whisper 上次没有装上": "Whisper no terminó de instalarse", + "这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。": "Este vídeo no tiene subtítulos y la última instalación de Whisper no tuvo éxito. Reinténtala en Ajustes → Transcripción, o vuelve a importarlo con un archivo .srt.", + "转写没有得到字幕": "La transcripción no produjo subtítulos", + "Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。": "Whisper está instalado, pero este vídeo no produjo voz utilizable. Comprueba que haya voz clara y reinténtalo, o vuelve a importarlo con un archivo .srt.", + "没有字幕可分析": "No hay subtítulos para analizar", + "这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。": "Este vídeo no tiene subtítulos y la transcripción local no produjo resultado. Instala o reintenta Whisper en Ajustes → Transcripción, o vuelve a importarlo con un archivo .srt.", + "到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。": "En Ajustes → Transcripción, comprueba que Whisper esté instalado y que haya un modelo descargado, o vuelve a importarlo con un archivo .srt.", + "打开转写设置": "Abrir ajustes de transcripción", + "转写设置": "Transcripción" } diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 131c97d7..fa3f637b 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -777,5 +777,16 @@ "操作教程": "Tutoriel", "海外": "À l’étranger", "贴三个 Cookie 后保存。配好后,发布页会出现 B 站。": "Collez les trois cookies et enregistrez. Bilibili apparaît alors sur la page de publication.", - "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Indiquez la clé Upload-Post ici. Ensuite, choisissez-la sur la page de publication de l’extrait." + "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Indiquez la clé Upload-Post ici. Ensuite, choisissez-la sur la page de publication de l’extrait.", + "还没有安装本地转写": "La transcription locale n’est pas installée", + "这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。": "Cette vidéo n’a pas de sous-titres. Installez Whisper et téléchargez un modèle dans Réglages → Transcription, ou importez-la à nouveau avec un fichier .srt.", + "Whisper 上次没有装上": "L’installation de Whisper n’a pas abouti", + "这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。": "Cette vidéo n’a pas de sous-titres, et la dernière installation de Whisper a échoué. Réessayez dans Réglages → Transcription, ou importez-la à nouveau avec un fichier .srt.", + "转写没有得到字幕": "La transcription n’a pas produit de sous-titres", + "Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。": "Whisper est installé, mais cette vidéo n’a pas produit de voix exploitable. Vérifiez qu’il y a une voix claire, puis réessayez, ou importez-la à nouveau avec un fichier .srt.", + "没有字幕可分析": "Aucun sous-titre à analyser", + "这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。": "Cette vidéo n’a pas de sous-titres, et la transcription locale n’a rien produit. Installez ou réessayez Whisper dans Réglages → Transcription, ou importez-la à nouveau avec un fichier .srt.", + "到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。": "Dans Réglages → Transcription, vérifiez que Whisper est installé et qu’un modèle est téléchargé, ou importez la vidéo à nouveau avec un fichier .srt.", + "打开转写设置": "Ouvrir les réglages de transcription", + "转写设置": "Transcription" } diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index 186516a7..ee0d63d8 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -777,5 +777,16 @@ "操作教程": "操作ガイド", "海外": "海外", "贴三个 Cookie 后保存。配好后,发布页会出现 B 站。": "Cookie を 3 つ貼って保存します。設定後、公開ページに Bilibili が出ます。", - "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Upload-Post のキーを入れます。設定後、切片の公開ページで選べます。" + "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Upload-Post のキーを入れます。設定後、切片の公開ページで選べます。", + "还没有安装本地转写": "ローカル文字起こしが未インストールです", + "这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。": "この動画には字幕がありません。「設定 → 文字起こし」で Whisper をインストールしてモデルをダウンロードするか、.srt を添えて読み込み直してください。", + "Whisper 上次没有装上": "Whisper のインストールが完了していません", + "这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。": "この動画には字幕がなく、前回の Whisper インストールも成功していません。「設定 → 文字起こし」でインストールをやり直すか、.srt を添えて読み込み直してください。", + "转写没有得到字幕": "文字起こしで字幕が得られませんでした", + "Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。": "Whisper はインストール済みですが、この動画から使える音声を認識できませんでした。明瞭な人声があるか確認して再試行するか、.srt を添えて読み込み直してください。", + "没有字幕可分析": "分析できる字幕がありません", + "这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。": "この動画には字幕がなく、ローカル文字起こしも結果を出せませんでした。「設定 → 文字起こし」で Whisper をインストールまたは再試行するか、.srt を添えて読み込み直してください。", + "到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。": "「設定 → 文字起こし」で Whisper のインストールとモデルのダウンロードを確認するか、.srt を添えて読み込み直してください。", + "打开转写设置": "文字起こし設定を開く", + "转写设置": "文字起こし設定" } diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index d4aeffd0..ddd7854a 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -777,5 +777,16 @@ "操作教程": "사용 방법", "海外": "해외", "贴三个 Cookie 后保存。配好后,发布页会出现 B 站。": "쿠키 세 개를 붙인 뒤 저장하세요. 설정되면 게시 페이지에 빌리빌리가 나타납니다.", - "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "여기에 Upload-Post 키를 넣으세요. 설정되면 클립 게시 페이지에서 고를 수 있습니다." + "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "여기에 Upload-Post 키를 넣으세요. 설정되면 클립 게시 페이지에서 고를 수 있습니다.", + "还没有安装本地转写": "로컬 전사가 설치되어 있지 않습니다", + "这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。": "이 동영상에는 자막이 없습니다. 「설정 → 전사」에서 Whisper를 설치하고 모델을 받거나, .srt를 함께 다시 가져오세요.", + "Whisper 上次没有装上": "Whisper 설치가 끝나지 않았습니다", + "这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。": "이 동영상에는 자막이 없고, 지난 Whisper 설치도 성공하지 못했습니다. 「설정 → 전사」에서 설치를 다시 시도하거나 .srt를 함께 다시 가져오세요.", + "转写没有得到字幕": "전사에서 자막을 얻지 못했습니다", + "Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。": "Whisper는 설치되어 있지만, 이 동영상에서 쓸 수 있는 음성을 인식하지 못했습니다. 또렷한 말소리가 있는지 확인한 뒤 다시 시도하거나 .srt를 함께 다시 가져오세요.", + "没有字幕可分析": "분석할 자막이 없습니다", + "这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。": "이 동영상에는 자막이 없고, 로컬 전사도 결과를 만들지 못했습니다. 「설정 → 전사」에서 Whisper를 설치하거나 다시 시도하고, .srt를 함께 다시 가져올 수도 있습니다.", + "到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。": "「설정 → 전사」에서 Whisper 설치와 모델 다운로드를 확인하거나, .srt를 함께 다시 가져오세요.", + "打开转写设置": "전사 설정 열기", + "转写设置": "전사 설정" } diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 03a6b9cb..633c0057 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -777,5 +777,16 @@ "操作教程": "Como fazer", "海外": "Exterior", "贴三个 Cookie 后保存。配好后,发布页会出现 B 站。": "Cole os três cookies e salve. O Bilibili passa a aparecer na página de publicação.", - "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Coloque a chave do Upload-Post aqui. Depois escolha na página de publicação do clipe." + "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Coloque a chave do Upload-Post aqui. Depois escolha na página de publicação do clipe.", + "还没有安装本地转写": "A transcrição local não está instalada", + "这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。": "Este vídeo não tem legendas. Instale o Whisper e baixe um modelo em Configurações → Transcrição, ou importe de novo com um arquivo .srt.", + "Whisper 上次没有装上": "O Whisper não terminou de instalar", + "这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。": "Este vídeo não tem legendas, e a última instalação do Whisper não deu certo. Tente de novo em Configurações → Transcrição, ou importe de novo com um arquivo .srt.", + "转写没有得到字幕": "A transcrição não gerou legendas", + "Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。": "O Whisper está instalado, mas este vídeo não produziu fala utilizável. Confirme que há fala clara e tente de novo, ou importe de novo com um arquivo .srt.", + "没有字幕可分析": "Não há legendas para analisar", + "这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。": "Este vídeo não tem legendas, e a transcrição local não produziu resultado. Instale ou tente o Whisper de novo em Configurações → Transcrição, ou importe de novo com um arquivo .srt.", + "到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。": "Em Configurações → Transcrição, confira se o Whisper está instalado e se há um modelo baixado, ou importe de novo com um arquivo .srt.", + "打开转写设置": "Abrir configurações de transcrição", + "转写设置": "Transcrição" } diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index d1022e59..5f9e9326 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -777,5 +777,16 @@ "操作教程": "Инструкция", "海外": "За рубеж", "贴三个 Cookie 后保存。配好后,发布页会出现 B 站。": "Вставьте три cookie и сохраните. После этого Bilibili появится на странице публикации.", - "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Укажите ключ Upload-Post. Затем выберите его на странице публикации клипа." + "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "Укажите ключ Upload-Post. Затем выберите его на странице публикации клипа.", + "还没有安装本地转写": "Локальная транскрибация не установлена", + "这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。": "У этого видео нет субтитров. Установите Whisper и скачайте модель в «Настройки → Транскрибация» или импортируйте его снова с файлом .srt.", + "Whisper 上次没有装上": "Установка Whisper не завершилась", + "这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。": "У этого видео нет субтитров, и прошлая установка Whisper не удалась. Повторите установку в «Настройки → Транскрибация» или импортируйте видео снова с файлом .srt.", + "转写没有得到字幕": "Транскрибация не дала субтитров", + "Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。": "Whisper установлен, но в этом видео не распознана разборчивая речь. Проверьте, что речь слышна, и повторите, или импортируйте видео снова с файлом .srt.", + "没有字幕可分析": "Нет субтитров для анализа", + "这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。": "У этого видео нет субтитров, и локальная транскрибация ничего не дала. Установите или повторите установку Whisper в «Настройки → Транскрибация», либо импортируйте видео снова с файлом .srt.", + "到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。": "В «Настройки → Транскрибация» проверьте, что Whisper установлен и модель скачана, или импортируйте видео снова с файлом .srt.", + "打开转写设置": "Открыть настройки транскрибации", + "转写设置": "Транскрибация" } diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index cccf40ec..bb9b6b5c 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -777,5 +777,16 @@ "操作教程": "操作教程", "海外": "海外", "贴三个 Cookie 后保存。配好后,发布页会出现 B 站。": "贴三个 Cookie 后保存。配好后,发布页会出现 B 站。", - "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。" + "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。": "在 Upload-Post 填密钥。配好后,在切片的发布页直接选。", + "还没有安装本地转写": "还没有安装本地转写", + "这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。": "这条视频没有自带字幕。到「设置 → 转写」安装 Whisper 并下载一个模型,或重新导入时带上 .srt。", + "Whisper 上次没有装上": "Whisper 上次没有装上", + "这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。": "这条视频没有自带字幕,而且上次安装 Whisper 没有成功。到「设置 → 转写」重试安装,或重新导入时带上 .srt。", + "转写没有得到字幕": "转写没有得到字幕", + "Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。": "Whisper 已经装好,但这条视频没有识别出可用语音。确认有清晰人声后重试,或重新导入时带上 .srt。", + "没有字幕可分析": "没有字幕可分析", + "这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。": "这条视频没有自带字幕,本地转写也没有结果。可以到「设置 → 转写」安装或重试 Whisper,也可以重新导入时带上 .srt。", + "到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。": "到「设置 → 转写」检查 Whisper 是否安装、模型是否已下载,或重新导入时带上 .srt。", + "打开转写设置": "打开转写设置", + "转写设置": "转写设置" } diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx index 0a36eab5..c4cebf75 100644 --- a/frontend/src/pages/ProjectDetailPage.tsx +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -5,7 +5,9 @@ import { useParams, useNavigate } from 'react-router-dom' import { message } from 'antd' import dayjs from 'dayjs' import { useProjectStore, Clip, Collection } from '../store/useProjectStore' -import { projectApi } from '../services/api' +import { projectApi, speechApi } from '../services/api' +import SubtitleFailureEmpty from '../components/SubtitleFailureEmpty' +import { classifySubtitleFailure, type SubtitleFailureKind } from '../utils/subtitleFailure' import ClipCard from '../components/ClipCard' import CollectionCard from '../components/CollectionCard' import CollectionPreviewModal from '../components/CollectionPreviewModal' @@ -47,6 +49,33 @@ const ProjectDetailPage: React.FC = () => { loadProcessingStatus() }, [id]) + const subtitleKind = classifySubtitleFailure(currentProject?.error_message, currentProject?.error_code) + const [refinedSubtitle, setRefinedSubtitle] = useState<{ id?: string; kind: SubtitleFailureKind } | null>(null) + useEffect(() => { + // 1.3.2 只留下一句「本地转写没有生成结果」。升级后按这台机器现在的 Whisper 状态说清楚下一步。 + if (subtitleKind !== 'subtitle_unknown' || !currentProject?.id) return + const projectId = currentProject.id + let cancelled = false + speechApi.getRuntimeStatus() + .then((rt) => { + if (cancelled) return + const kind: SubtitleFailureKind | null = rt.status === 'error' + ? 'whisper_install_failed' + : rt.status === 'installed' + ? 'transcription_empty' + : rt.status === 'not_installed' + ? 'whisper_not_installed' + : null + if (kind) setRefinedSubtitle({ id: projectId, kind }) + }) + .catch(() => { /* 问不到状态时保留通用说明 */ }) + return () => { cancelled = true } + }, [subtitleKind, currentProject?.id]) + const refinedKind = refinedSubtitle && refinedSubtitle.id === currentProject?.id ? refinedSubtitle.kind : null + const shownSubtitleKind = subtitleKind === 'subtitle_unknown' + ? (refinedKind || subtitleKind) + : subtitleKind + const loadProject = async () => { if (!id) return try { @@ -269,9 +298,9 @@ const ProjectDetailPage: React.FC = () => { {t("开始处理")} )} {isFailed && ( -
+
setFeedbackOpen(true)}>{t("反馈问题")} - {t("重试")} + {t("重试")}
)}
@@ -354,11 +383,17 @@ const ProjectDetailPage: React.FC = () => { )} + ) : isFailed && shownSubtitleKind ? ( + navigate('/settings?section=speech')} + /> ) : isFailed ? (
{t("这次处理没有成功")} {currentProject.error_message ? ( - + {currentProject.error_message} ) : ( diff --git a/frontend/src/store/useProjectStore.ts b/frontend/src/store/useProjectStore.ts index 7cbb2946..9386c626 100644 --- a/frontend/src/store/useProjectStore.ts +++ b/frontend/src/store/useProjectStore.ts @@ -58,6 +58,7 @@ export interface Project { current_step?: number total_steps?: number error_message?: string + error_code?: string } interface ProjectStore { diff --git a/frontend/src/utils/subtitleFailure.ts b/frontend/src/utils/subtitleFailure.ts new file mode 100644 index 00000000..c1dbf171 --- /dev/null +++ b/frontend/src/utils/subtitleFailure.ts @@ -0,0 +1,45 @@ +/** 没有字幕可分析:前端用来决定要不要打开「设置 → 转写」,以及换哪一句说明。 */ +export type SubtitleFailureKind = + | 'whisper_not_installed' + | 'whisper_install_failed' + | 'transcription_empty' + | 'subtitle_setup' + | 'subtitle_unknown' + +const CODES = new Set([ + 'whisper_not_installed', + 'whisper_install_failed', + 'transcription_empty', + 'subtitle_setup', + 'subtitle_unknown', +]) + +function mentionsSubtitle(text: string): boolean { + return /字幕|转写|Whisper|whisper/.test(text) +} + +/** + * 有 error_code 时以它为准。旧版只存了一句「没有字幕可分析…本地转写没有生成结果」, + * 那种情况返回 subtitle_unknown,详情页可以再问一次当前 Whisper 状态。 + */ +export function classifySubtitleFailure(error?: string | null, code?: string | null): SubtitleFailureKind | null { + if (code && CODES.has(code as SubtitleFailureKind)) return code as SubtitleFailureKind + const text = error || '' + if (!text || !mentionsSubtitle(text)) return null + if (text.includes('上次安装没有成功') || text.includes('安装没有成功')) return 'whisper_install_failed' + if (text.includes('还没安装') || text.includes('运行时未安装') || text.includes('没有可用的语音识别')) { + return 'whisper_not_installed' + } + if ( + text.includes('没有生成可用字幕') + || text.includes('未识别出任何语音') + || text.includes('没有从这段视频') + ) { + return 'transcription_empty' + } + if (text.includes('本地转写没有生成结果')) return 'subtitle_unknown' + if (text.includes('没有字幕可分析') || text.includes('设置 → 语音识别') || text.includes('设置 → 转写')) { + return 'subtitle_setup' + } + return null +} diff --git a/frontend/tests/subtitle-failure.test.cjs b/frontend/tests/subtitle-failure.test.cjs new file mode 100644 index 00000000..6c1e3c8a --- /dev/null +++ b/frontend/tests/subtitle-failure.test.cjs @@ -0,0 +1,47 @@ +const { test } = require('node:test') +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const ts = require('typescript') + +function loadClassifier() { + const file = path.join(__dirname, '../src/utils/subtitleFailure.ts') + const code = ts.transpileModule(fs.readFileSync(file, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText + const module = { exports: {} } + vm.runInNewContext(code, { module, exports: module.exports }) + return module.exports +} + +const { classifySubtitleFailure } = loadClassifier() + +const LEGACY = '没有字幕可分析:视频不带字幕,且本地转写没有生成结果。 到「设置 → 转写」安装 Whisper 模型让 AutoClip 自动转写,或导入 .srt 字幕后重试。' + +test('classifies structured codes and the four user-facing cases', () => { + assert.equal(classifySubtitleFailure('anything', 'whisper_not_installed'), 'whisper_not_installed') + assert.equal(classifySubtitleFailure('anything', 'whisper_install_failed'), 'whisper_install_failed') + assert.equal(classifySubtitleFailure('anything', 'transcription_empty'), 'transcription_empty') + assert.equal(classifySubtitleFailure('显卡失败', 'subtitle_setup'), 'subtitle_setup') + assert.equal(classifySubtitleFailure(LEGACY), 'subtitle_unknown') + assert.equal( + classifySubtitleFailure('没有字幕可分析:视频不带字幕,且本地 Whisper 还没安装。到「设置 → 转写」安装。'), + 'whisper_not_installed', + ) + assert.equal( + classifySubtitleFailure('没有字幕可分析:视频不带字幕,且 Whisper 上次安装没有成功。'), + 'whisper_install_failed', + ) + assert.equal( + classifySubtitleFailure('没有字幕可分析:Whisper 已安装,但这次转写没有生成可用字幕。'), + 'transcription_empty', + ) +}) + +test('does not treat unrelated failures as a transcription settings problem', () => { + assert.equal(classifySubtitleFailure('没有可用的 LLM 提供商。请到「设置 → 模型」检查。'), null) + assert.equal(classifySubtitleFailure('没有片段通过评分筛选。到「设置 → 模型 → 最低评分阈值」调低后重试。'), null) + assert.equal(classifySubtitleFailure(''), null) + assert.equal(classifySubtitleFailure(null, 'not_a_code'), null) +}) diff --git a/skills/autoclip/SKILL.md b/skills/autoclip/SKILL.md index 28330b70..b50e6d86 100644 --- a/skills/autoclip/SKILL.md +++ b/skills/autoclip/SKILL.md @@ -77,7 +77,7 @@ autoclip publish --list-profiles / autoclip publish --status ## 常见问题 -- **没有字幕、首次运行很慢**:Whisper 模型首次下载;提前告知用户。Whisper 未安装时 `check_environment.whisper.ok=false`,让用户在桌面应用「设置 → 语音识别」一键安装,或传 `srt_path`。 +- **没有字幕、首次运行很慢**:Whisper 模型首次下载;提前告知用户。Whisper 未安装时 `check_environment.whisper.ok=false`,让用户在桌面应用「设置 → 转写」一键安装,或传 `srt_path`。 - **模型连接失败**:`check_environment.llm.error` 给出了原因。`ollama` 不可达 → 提示 `ollama serve` 并 `ollama pull qwen2.5:7b`;云端 provider → 缺 key,让用户在桌面应用设置页填,或传 `api_key`。 - **切片为 0**:降低 `min_score` 到 0.5;仍为 0 说明内容本身不适合切高光(如纯音乐 / 无对话)。 - **系统代理导致本地地址 502**:AutoClip 对 localhost / 内网地址自动绕过代理,不需要用户改 Clash 规则。