mirror of
https://github.com/zhouxiaoka/autoclip.git
synced 2026-10-02 02:34:34 +08:00
fix: 没有字幕时指出该去设置里的转写 (#171)
区分 Whisper 未安装、安装失败和转写为空,并在失败页打开「设置 → 转写」。安装失败不再被状态轮询盖掉。 Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kris K <zhouxiaoka@users.noreply.github.com>
This commit is contained in:
co-authored by
Cursor Agent
Kris K
parent
f0945728b9
commit
ea4fdbe8b5
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 异常。
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ pip install -e . # 得到 autoclip / autoclip-mcp 两个命令
|
||||
不装包也能用:`python -m backend.cli ...`(在仓库根目录)。
|
||||
|
||||
需要 ffmpeg 在 PATH(`brew install ffmpeg`)。没有字幕的视频要本地 Whisper:`pip install faster-whisper`,
|
||||
或在桌面应用「设置 → 语音识别」一键安装(两者共用模型目录)。
|
||||
或在桌面应用「设置 → 转写」一键安装(两者共用模型目录)。
|
||||
|
||||
先体检:
|
||||
|
||||
|
||||
@@ -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<ProjectCardProps> = ({ 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<ProjectCardProps> = ({ project, onDelete, onRetry, o
|
||||
{/* 状态和统计信息 — Calm Premium,见 DESIGN.md */}
|
||||
{(normalizedStatus === 'importing' || normalizedStatus === 'downloading' || normalizedStatus === 'processing' || normalizedStatus === 'failed') ? (
|
||||
// 进行中 / 失败:细进度线或终态点,占满宽度
|
||||
<div style={{ marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ marginBottom: '2px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<UnifiedStatusBar
|
||||
projectId={project.id}
|
||||
status={normalizedStatus}
|
||||
@@ -635,7 +640,10 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, onDelete, onRetry, o
|
||||
/>
|
||||
{normalizedStatus === 'failed' && (
|
||||
// 失败态:重试 + 反馈(反馈自动带上阶段 / 错误 / 版本 / 模型上下文)
|
||||
<div style={{ display: 'flex', gap: 2, flex: '0 0 auto' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', gap: 2, flex: '0 0 auto', marginLeft: 'auto' }} onClick={(e) => e.stopPropagation()}>
|
||||
{subtitleFailure && (
|
||||
<Btn variant="text" size="sm" style={{ height: 26, padding: '0 8px', fontSize: 12.5 }} onClick={() => navigate('/settings?section=speech')}>{t("转写设置")}</Btn>
|
||||
)}
|
||||
<Btn variant="text" size="sm" style={{ height: 26, padding: '0 8px', fontSize: 12.5 }} loading={isRetrying} onClick={() => handleRetry()}>{t("重试")}</Btn>
|
||||
<Btn variant="text" size="sm" style={{ height: 26, padding: '0 8px', fontSize: 12.5 }} onClick={() => setFeedbackOpen(true)}>{t("反馈")}</Btn>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="ac-empty" style={{ marginTop: 32 }}>
|
||||
<b>{copy.title}</b>
|
||||
<span style={{ display: 'block', maxWidth: 520, margin: '6px auto 0', lineHeight: 1.5 }}>{copy.body}</span>
|
||||
{errorMessage && (
|
||||
<span className="ac-mono" style={{ display: 'block', marginTop: 10, color: 'var(--ac-muted)', wordBreak: 'break-word' }}>
|
||||
{errorMessage}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Btn variant="cta" size="sm" onClick={onOpenSettings}>{t("打开转写设置")}</Btn>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubtitleFailureEmpty
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 を添えて読み込み直してください。",
|
||||
"打开转写设置": "文字起こし設定を開く",
|
||||
"转写设置": "文字起こし設定"
|
||||
}
|
||||
|
||||
@@ -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를 함께 다시 가져오세요.",
|
||||
"打开转写设置": "전사 설정 열기",
|
||||
"转写设置": "전사 설정"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
"打开转写设置": "Открыть настройки транскрибации",
|
||||
"转写设置": "Транскрибация"
|
||||
}
|
||||
|
||||
@@ -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。",
|
||||
"打开转写设置": "打开转写设置",
|
||||
"转写设置": "转写设置"
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
<Btn variant="cta" onClick={handleStartProcessing} loading={statusLoading}>{t("开始处理")}</Btn>
|
||||
)}
|
||||
{isFailed && (
|
||||
<div style={{ display: 'flex', gap: 8, flex: '0 0 auto' }}>
|
||||
<div style={{ display: 'flex', gap: 8, flex: '0 0 auto', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<Btn onClick={() => setFeedbackOpen(true)}>{t("反馈问题")}</Btn>
|
||||
<Btn variant="cta" onClick={handleRetryProcessing} loading={statusLoading}>{t("重试")}</Btn>
|
||||
<Btn variant={shownSubtitleKind ? undefined : 'cta'} onClick={handleRetryProcessing} loading={statusLoading}>{t("重试")}</Btn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -354,11 +383,17 @@ const ProjectDetailPage: React.FC = () => {
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
) : isFailed && shownSubtitleKind ? (
|
||||
<SubtitleFailureEmpty
|
||||
kind={shownSubtitleKind}
|
||||
errorMessage={currentProject.error_message}
|
||||
onOpenSettings={() => navigate('/settings?section=speech')}
|
||||
/>
|
||||
) : isFailed ? (
|
||||
<div className="ac-empty" style={{ marginTop: 32 }}>
|
||||
<b>{t("这次处理没有成功")}</b>
|
||||
{currentProject.error_message ? (
|
||||
<span className="ac-mono" style={{ display: 'block', marginTop: 6, color: 'var(--ac-muted)', wordBreak: 'break-all' }}>
|
||||
<span className="ac-mono" style={{ display: 'block', marginTop: 6, color: 'var(--ac-muted)', wordBreak: 'break-word' }}>
|
||||
{currentProject.error_message}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface Project {
|
||||
current_step?: number
|
||||
total_steps?: number
|
||||
error_message?: string
|
||||
error_code?: string
|
||||
}
|
||||
|
||||
interface ProjectStore {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/** 没有字幕可分析:前端用来决定要不要打开「设置 → 转写」,以及换哪一句说明。 */
|
||||
export type SubtitleFailureKind =
|
||||
| 'whisper_not_installed'
|
||||
| 'whisper_install_failed'
|
||||
| 'transcription_empty'
|
||||
| 'subtitle_setup'
|
||||
| 'subtitle_unknown'
|
||||
|
||||
const CODES = new Set<SubtitleFailureKind>([
|
||||
'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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -77,7 +77,7 @@ autoclip publish --list-profiles / autoclip publish --status <request_id>
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **没有字幕、首次运行很慢**: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 规则。
|
||||
|
||||
Reference in New Issue
Block a user