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:
Kris K
2026-09-23 21:41:59 +08:00
committed by GitHub
co-authored by Cursor Agent Kris K
parent f0945728b9
commit ea4fdbe8b5
26 changed files with 560 additions and 46 deletions
+3
View File
@@ -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
+59 -1
View File
@@ -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")
+1
View File
@@ -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")
+4 -2
View File
@@ -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
+23 -8
View File
@@ -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,
+8 -8
View File
@@ -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)
+50 -6
View File
@@ -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
+63 -2
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
+12 -1
View File
@@ -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"
}
+12 -1
View File
@@ -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"
}
+12 -1
View File
@@ -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"
}
+12 -1
View File
@@ -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 を添えて読み込み直してください。",
"打开转写设置": "文字起こし設定を開く",
"转写设置": "文字起こし設定"
}
+12 -1
View File
@@ -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를 함께 다시 가져오세요.",
"打开转写设置": "전사 설정 열기",
"转写设置": "전사 설정"
}
+12 -1
View File
@@ -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"
}
+12 -1
View File
@@ -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.",
"打开转写设置": "Открыть настройки транскрибации",
"转写设置": "Транскрибация"
}
+12 -1
View File
@@ -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。",
"打开转写设置": "打开转写设置",
"转写设置": "转写设置"
}
+39 -4
View File
@@ -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>
) : (
+1
View File
@@ -58,6 +58,7 @@ export interface Project {
current_step?: number
total_steps?: number
error_message?: string
error_code?: string
}
interface ProjectStore {
+45
View File
@@ -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
}
+47
View File
@@ -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)
})
+1 -1
View File
@@ -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 规则。