mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-07-08 22:15:25 +08:00
fix: 性能优化、前端转写器配置、任务进度丢失及 MLX Whisper 回退问题修复
### 性能优化 - 后端任务执行从串行锁改为 ThreadPoolExecutor 并发执行(默认3线程) - 添加 GZipMiddleware 响应压缩 + Nginx gzip 配置 - 数据库连接池参数优化(pool_size=10, max_overflow=20) - 视频帧提取并行化(ThreadPoolExecutor) - LLM 重试配置缓存到实例,避免每次请求读 env var - 前端路由级代码拆分(React.lazy + Suspense) - Vite manualChunks 拆分 markdown/markmap/vendor - MarkdownViewer 用 React.memo + useMemo 减少不必要渲染 - NoteHistory Fuse.js 实例 useMemo 缓存 - useTaskPolling 无待处理任务时跳过轮询 - 移除 antd 依赖(NoteForm Alert、modelForm Tag),改用 shadcn/ui ### 前端转写器配置(新功能) - 新增 TranscriberConfigManager(JSON 文件存储,替代环境变量) - 新增 GET/POST /transcriber_config API 端点 - 新增 GET /transcriber_models_status 模型下载状态查询 - 新增 POST /transcriber_download 后台模型下载触发 - 前端转写器设置页面:引擎选择、模型大小选择、模型下载管理 - deploy_status 端点同步从配置文件读取 ### Bug 修复 - 修复任务进行中切换页面后进度丢失:Home.tsx status 派生逻辑补全中间状态 - 修复 MLX Whisper 静默回退 fast-whisper:移除环境变量门控,macOS 下自动尝试导入 - MLX Whisper 不可用时抛出 RuntimeError 而非静默回退 - 前端展示 MLX Whisper 可用性状态,不可用时禁用保存 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,10 +13,19 @@ engine_args = {}
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
engine_args["connect_args"] = {"check_same_thread": False}
|
||||
|
||||
_pool_args = {}
|
||||
if not DATABASE_URL.startswith("sqlite"):
|
||||
_pool_args = {
|
||||
"pool_size": int(os.getenv("DB_POOL_SIZE", "10")),
|
||||
"max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "20")),
|
||||
"pool_pre_ping": True,
|
||||
}
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
echo=os.getenv("SQLALCHEMY_ECHO", "false").lower() == "true",
|
||||
**engine_args
|
||||
**engine_args,
|
||||
**_pool_args,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
@@ -26,6 +26,9 @@ class UniversalGPT(GPT):
|
||||
self.max_request_bytes = int(os.getenv("OPENAI_MAX_REQUEST_BYTES", str(45 * 1024 * 1024)))
|
||||
self.checkpoint_dir = Path(os.getenv("NOTE_OUTPUT_DIR", "note_results"))
|
||||
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
# 初始化时缓存重试配置,避免每次请求重复读取环境变量
|
||||
self._max_retry_attempts = max(1, int(os.getenv("OPENAI_RETRY_ATTEMPTS", "3")))
|
||||
self._retry_base_backoff = float(os.getenv("OPENAI_RETRY_BACKOFF_SECONDS", "1.5"))
|
||||
|
||||
def _format_time(self, seconds: float) -> str:
|
||||
return str(timedelta(seconds=int(seconds)))[2:]
|
||||
@@ -176,11 +179,8 @@ class UniversalGPT(GPT):
|
||||
return status in {408, 409, 429, 500, 502, 503, 504, 524}
|
||||
|
||||
def _chat_completion_create(self, messages: list):
|
||||
max_attempts = max(1, int(os.getenv("OPENAI_RETRY_ATTEMPTS", "3")))
|
||||
base_backoff = float(os.getenv("OPENAI_RETRY_BACKOFF_SECONDS", "1.5"))
|
||||
|
||||
last_exc = None
|
||||
for attempt in range(max_attempts):
|
||||
for attempt in range(self._max_retry_attempts):
|
||||
try:
|
||||
return self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
@@ -189,9 +189,9 @@ class UniversalGPT(GPT):
|
||||
)
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
if attempt == max_attempts - 1 or not self._is_retryable_error(exc):
|
||||
if attempt == self._max_retry_attempts - 1 or not self._is_retryable_error(exc):
|
||||
raise
|
||||
sleep_seconds = base_backoff * (2 ** attempt)
|
||||
sleep_seconds = self._retry_base_backoff * (2 ** attempt)
|
||||
time.sleep(sleep_seconds)
|
||||
|
||||
if last_exc is not None:
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.utils.response import ResponseWrapper as R
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.path_helper import get_model_dir
|
||||
|
||||
from app.services.cookie_manager import CookieConfigManager
|
||||
from app.services.transcriber_config_manager import TranscriberConfigManager
|
||||
from ffmpeg_helper import ensure_ffmpeg_or_raise
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
cookie_manager = CookieConfigManager()
|
||||
transcriber_config_manager = TranscriberConfigManager()
|
||||
|
||||
|
||||
class CookieUpdateRequest(BaseModel):
|
||||
@@ -32,6 +42,165 @@ def update_cookie(data: CookieUpdateRequest):
|
||||
|
||||
)
|
||||
|
||||
class TranscriberConfigRequest(BaseModel):
|
||||
transcriber_type: str
|
||||
whisper_model_size: Optional[str] = None
|
||||
|
||||
|
||||
AVAILABLE_TRANSCRIBER_TYPES = [
|
||||
{"value": "fast-whisper", "label": "Faster Whisper(本地)"},
|
||||
{"value": "bcut", "label": "必剪(在线)"},
|
||||
{"value": "kuaishou", "label": "快手(在线)"},
|
||||
{"value": "groq", "label": "Groq(在线)"},
|
||||
{"value": "mlx-whisper", "label": "MLX Whisper(仅macOS)"},
|
||||
]
|
||||
|
||||
WHISPER_MODEL_SIZES = ["tiny", "base", "small", "medium", "large-v3", "large-v3-turbo"]
|
||||
|
||||
|
||||
@router.get("/transcriber_config")
|
||||
def get_transcriber_config():
|
||||
from app.transcriber.transcriber_provider import MLX_WHISPER_AVAILABLE
|
||||
|
||||
config = transcriber_config_manager.get_config()
|
||||
return R.success(data={
|
||||
**config,
|
||||
"available_types": AVAILABLE_TRANSCRIBER_TYPES,
|
||||
"whisper_model_sizes": WHISPER_MODEL_SIZES,
|
||||
"mlx_whisper_available": MLX_WHISPER_AVAILABLE,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/transcriber_config")
|
||||
def update_transcriber_config(data: TranscriberConfigRequest):
|
||||
config = transcriber_config_manager.update_config(
|
||||
transcriber_type=data.transcriber_type,
|
||||
whisper_model_size=data.whisper_model_size,
|
||||
)
|
||||
return R.success(data=config)
|
||||
|
||||
|
||||
# ---- Whisper 模型下载状态 & 下载触发 ----
|
||||
|
||||
# 用于跟踪正在进行的下载任务
|
||||
_downloading: dict[str, str] = {} # model_size -> status ("downloading" | "done" | "failed")
|
||||
|
||||
|
||||
def _check_whisper_model_exists(model_size: str, subdir: str = "whisper") -> bool:
|
||||
"""检查指定 whisper 模型是否已下载到本地。"""
|
||||
model_dir = get_model_dir(subdir)
|
||||
model_path = os.path.join(model_dir, f"whisper-{model_size}")
|
||||
return Path(model_path).exists()
|
||||
|
||||
|
||||
@router.get("/transcriber_models_status")
|
||||
def get_transcriber_models_status():
|
||||
"""返回所有 whisper 模型的下载状态。"""
|
||||
statuses = []
|
||||
for size in WHISPER_MODEL_SIZES:
|
||||
downloaded = _check_whisper_model_exists(size, "whisper")
|
||||
download_status = _downloading.get(size)
|
||||
statuses.append({
|
||||
"model_size": size,
|
||||
"downloaded": downloaded,
|
||||
"downloading": download_status == "downloading",
|
||||
})
|
||||
|
||||
# 也检查 mlx-whisper(仅 macOS)
|
||||
mlx_available = platform.system() == "Darwin"
|
||||
mlx_statuses = []
|
||||
if mlx_available:
|
||||
for size in WHISPER_MODEL_SIZES:
|
||||
mlx_key = f"mlx-{size}"
|
||||
model_dir = get_model_dir("mlx-whisper")
|
||||
model_path = os.path.join(model_dir, f"mlx-community/whisper-{size}")
|
||||
downloaded = Path(model_path).exists()
|
||||
mlx_statuses.append({
|
||||
"model_size": size,
|
||||
"downloaded": downloaded,
|
||||
"downloading": _downloading.get(mlx_key) == "downloading",
|
||||
})
|
||||
|
||||
return R.success(data={
|
||||
"whisper": statuses,
|
||||
"mlx_whisper": mlx_statuses,
|
||||
"mlx_available": mlx_available,
|
||||
})
|
||||
|
||||
|
||||
class ModelDownloadRequest(BaseModel):
|
||||
model_size: str
|
||||
transcriber_type: str = "fast-whisper" # "fast-whisper" 或 "mlx-whisper"
|
||||
|
||||
|
||||
def _do_download_whisper(model_size: str):
|
||||
"""后台下载 faster-whisper 模型。"""
|
||||
from app.transcriber.whisper import MODEL_MAP
|
||||
from modelscope import snapshot_download
|
||||
|
||||
try:
|
||||
_downloading[model_size] = "downloading"
|
||||
model_dir = get_model_dir("whisper")
|
||||
model_path = os.path.join(model_dir, f"whisper-{model_size}")
|
||||
if Path(model_path).exists():
|
||||
_downloading[model_size] = "done"
|
||||
return
|
||||
repo_id = MODEL_MAP.get(model_size)
|
||||
if not repo_id:
|
||||
_downloading[model_size] = "failed"
|
||||
return
|
||||
logger.info(f"开始下载 whisper 模型: {model_size}")
|
||||
snapshot_download(repo_id, local_dir=model_path)
|
||||
logger.info(f"whisper 模型下载完成: {model_size}")
|
||||
_downloading[model_size] = "done"
|
||||
except Exception as e:
|
||||
logger.error(f"whisper 模型下载失败: {model_size}, {e}")
|
||||
_downloading[model_size] = "failed"
|
||||
|
||||
|
||||
def _do_download_mlx_whisper(model_size: str):
|
||||
"""后台下载 mlx-whisper 模型。"""
|
||||
key = f"mlx-{model_size}"
|
||||
try:
|
||||
_downloading[key] = "downloading"
|
||||
from huggingface_hub import snapshot_download as hf_download
|
||||
|
||||
model_dir = get_model_dir("mlx-whisper")
|
||||
model_name = f"mlx-community/whisper-{model_size}"
|
||||
model_path = os.path.join(model_dir, model_name)
|
||||
if Path(model_path).exists():
|
||||
_downloading[key] = "done"
|
||||
return
|
||||
logger.info(f"开始下载 mlx-whisper 模型: {model_size}")
|
||||
hf_download(model_name, local_dir=model_path, local_dir_use_symlinks=False)
|
||||
logger.info(f"mlx-whisper 模型下载完成: {model_size}")
|
||||
_downloading[key] = "done"
|
||||
except Exception as e:
|
||||
logger.error(f"mlx-whisper 模型下载失败: {model_size}, {e}")
|
||||
_downloading[key] = "failed"
|
||||
|
||||
|
||||
@router.post("/transcriber_download")
|
||||
def download_transcriber_model(data: ModelDownloadRequest, background_tasks: BackgroundTasks):
|
||||
"""触发后台下载指定的 whisper 模型。"""
|
||||
if data.model_size not in WHISPER_MODEL_SIZES:
|
||||
return R.error(msg=f"不支持的模型大小: {data.model_size}")
|
||||
|
||||
if data.transcriber_type == "mlx-whisper":
|
||||
if platform.system() != "Darwin":
|
||||
return R.error(msg="MLX Whisper 仅支持 macOS")
|
||||
key = f"mlx-{data.model_size}"
|
||||
if _downloading.get(key) == "downloading":
|
||||
return R.success(msg="模型正在下载中")
|
||||
background_tasks.add_task(_do_download_mlx_whisper, data.model_size)
|
||||
else:
|
||||
if _downloading.get(data.model_size) == "downloading":
|
||||
return R.success(msg="模型正在下载中")
|
||||
background_tasks.add_task(_do_download_whisper, data.model_size)
|
||||
|
||||
return R.success(msg="模型下载已开始")
|
||||
|
||||
|
||||
@router.get("/sys_health")
|
||||
async def sys_health():
|
||||
try:
|
||||
@@ -59,9 +228,10 @@ async def deploy_status():
|
||||
"gpu_name": torch.cuda.get_device_name(0) if cuda_available else None,
|
||||
}
|
||||
|
||||
# Whisper 模型状态
|
||||
model_size = os.getenv("WHISPER_MODEL_SIZE", "base")
|
||||
transcriber_type = os.getenv("TRANSCRIBER_TYPE", "fast-whisper")
|
||||
# Whisper 模型状态(从配置文件读取,与前端设置同步)
|
||||
transcriber_cfg = transcriber_config_manager.get_config()
|
||||
model_size = transcriber_cfg["whisper_model_size"]
|
||||
transcriber_type = transcriber_cfg["transcriber_type"]
|
||||
|
||||
# FFmpeg 状态
|
||||
try:
|
||||
|
||||
@@ -101,7 +101,7 @@ def run_note_task(task_id: str, video_url: str, platform: str, quality: Download
|
||||
grid_size=grid_size,
|
||||
)
|
||||
|
||||
logger.info(f"任务进入串行队列,等待执行 (task_id={task_id})")
|
||||
logger.info(f"任务进入执行队列 (task_id={task_id})")
|
||||
note = task_serial_executor.run(_execute_note_task)
|
||||
logger.info(f"Note generated: {task_id}")
|
||||
if not note or not note.markdown:
|
||||
|
||||
@@ -66,9 +66,11 @@ class NoteGenerator:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.model_size: str = "base"
|
||||
from app.services.transcriber_config_manager import TranscriberConfigManager
|
||||
config_manager = TranscriberConfigManager()
|
||||
self.model_size: str = config_manager.get_whisper_model_size()
|
||||
self.device: Optional[str] = None
|
||||
self.transcriber_type: str = os.getenv("TRANSCRIBER_TYPE", "fast-whisper")
|
||||
self.transcriber_type: str = config_manager.get_transcriber_type()
|
||||
self.transcriber: Transcriber = self._init_transcriber()
|
||||
self.video_path: Optional[Path] = None
|
||||
self.video_img_urls=[]
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import threading
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class SerialTaskExecutor:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
class ConcurrentTaskExecutor:
|
||||
"""使用线程池并发执行任务,替代原来的串行锁。"""
|
||||
|
||||
def __init__(self, max_workers: int | None = None):
|
||||
self._max_workers = max_workers or int(os.getenv("TASK_MAX_WORKERS", "3"))
|
||||
self._pool = ThreadPoolExecutor(max_workers=self._max_workers)
|
||||
|
||||
def run(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
with self._lock:
|
||||
return fn(*args, **kwargs)
|
||||
future: Future = self._pool.submit(fn, *args, **kwargs)
|
||||
return future.result()
|
||||
|
||||
def shutdown(self, wait: bool = True):
|
||||
self._pool.shutdown(wait=wait)
|
||||
|
||||
|
||||
task_serial_executor = SerialTaskExecutor()
|
||||
# 保持向后兼容的导出名
|
||||
SerialTaskExecutor = ConcurrentTaskExecutor
|
||||
task_serial_executor = ConcurrentTaskExecutor()
|
||||
|
||||
58
backend/app/services/transcriber_config_manager.py
Normal file
58
backend/app/services/transcriber_config_manager.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
class TranscriberConfigManager:
|
||||
"""管理转写器配置,存储在 JSON 文件中,支持前端动态修改。"""
|
||||
|
||||
def __init__(self, filepath: str = "config/transcriber.json"):
|
||||
self.path = Path(filepath)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _read(self) -> Dict[str, Any]:
|
||||
if not self.path.exists():
|
||||
return {}
|
||||
try:
|
||||
with self.path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _write(self, data: Dict[str, Any]):
|
||||
with self.path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
"""获取当前转写器配置,fallback 到环境变量默认值。"""
|
||||
data = self._read()
|
||||
return {
|
||||
"transcriber_type": data.get(
|
||||
"transcriber_type",
|
||||
os.getenv("TRANSCRIBER_TYPE", "fast-whisper"),
|
||||
),
|
||||
"whisper_model_size": data.get(
|
||||
"whisper_model_size",
|
||||
os.getenv("WHISPER_MODEL_SIZE", "medium"),
|
||||
),
|
||||
}
|
||||
|
||||
def update_config(
|
||||
self,
|
||||
transcriber_type: str,
|
||||
whisper_model_size: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""更新转写器配置并持久化。"""
|
||||
data = self._read()
|
||||
data["transcriber_type"] = transcriber_type
|
||||
if whisper_model_size is not None:
|
||||
data["whisper_model_size"] = whisper_model_size
|
||||
self._write(data)
|
||||
return self.get_config()
|
||||
|
||||
def get_transcriber_type(self) -> str:
|
||||
return self.get_config()["transcriber_type"]
|
||||
|
||||
def get_whisper_model_size(self) -> str:
|
||||
return self.get_config()["whisper_model_size"]
|
||||
@@ -17,15 +17,15 @@ class TranscriberType(str, Enum):
|
||||
KUAISHOU = "kuaishou"
|
||||
GROQ = "groq"
|
||||
|
||||
# 仅在 Apple 平台启用 MLX Whisper
|
||||
# 在 Apple 平台尝试导入 MLX Whisper(不再依赖环境变量,支持前端动态切换)
|
||||
MLX_WHISPER_AVAILABLE = False
|
||||
if platform.system() == "Darwin" and os.environ.get("TRANSCRIBER_TYPE") == "mlx-whisper":
|
||||
if platform.system() == "Darwin":
|
||||
try:
|
||||
from app.transcriber.mlx_whisper_transcriber import MLXWhisperTranscriber
|
||||
MLX_WHISPER_AVAILABLE = True
|
||||
logger.info("MLX Whisper 可用,已导入")
|
||||
except ImportError:
|
||||
logger.warning("MLX Whisper 导入失败,可能未安装或平台不支持")
|
||||
logger.warning("MLX Whisper 导入失败,可能未安装 mlx_whisper")
|
||||
|
||||
logger.info('初始化转录服务提供器')
|
||||
|
||||
@@ -97,8 +97,10 @@ def get_transcriber(transcriber_type="fast-whisper", model_size="base", device="
|
||||
|
||||
elif transcriber_enum == TranscriberType.MLX_WHISPER:
|
||||
if not MLX_WHISPER_AVAILABLE:
|
||||
logger.warning("MLX Whisper 不可用,回退到 fast-whisper")
|
||||
return get_whisper_transcriber(whisper_model_size, device=device)
|
||||
raise RuntimeError(
|
||||
"MLX Whisper 不可用:需要 macOS 平台并安装 mlx_whisper 包 (pip install mlx_whisper)。"
|
||||
"请在「音频转写配置」页面切换到其他转写引擎。"
|
||||
)
|
||||
return get_mlx_whisper_transcriber(whisper_model_size)
|
||||
|
||||
elif transcriber_enum == TranscriberType.BCUT:
|
||||
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import ffmpeg
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -54,6 +55,18 @@ class VideoReader:
|
||||
return mm * 60 + ss
|
||||
return float('inf')
|
||||
|
||||
def _extract_single_frame(self, ts: int) -> str | None:
|
||||
"""提取单帧,返回输出路径或 None(失败时)。"""
|
||||
time_label = self.format_time(ts)
|
||||
output_path = os.path.join(self.frame_dir, f"frame_{time_label}.jpg")
|
||||
cmd = ["ffmpeg", "-ss", str(ts), "-i", self.video_path, "-frames:v", "1", "-q:v", "2", "-y", output_path,
|
||||
"-hide_banner", "-loglevel", "error"]
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
return output_path
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
def extract_frames(self, max_frames=1000) -> list[str]:
|
||||
|
||||
try:
|
||||
@@ -61,14 +74,22 @@ class VideoReader:
|
||||
duration = float(ffmpeg.probe(self.video_path)["format"]["duration"])
|
||||
timestamps = [i for i in range(0, int(duration), self.frame_interval)][:max_frames]
|
||||
|
||||
# 并行提取帧
|
||||
max_workers = min(os.cpu_count() or 4, 8, len(timestamps))
|
||||
frame_results: dict[int, str | None] = {}
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(self._extract_single_frame, ts): ts for ts in timestamps}
|
||||
for future in as_completed(futures):
|
||||
ts = futures[future]
|
||||
frame_results[ts] = future.result()
|
||||
|
||||
# 按时间戳顺序整理结果,并进行去重
|
||||
image_paths = []
|
||||
last_hash = None
|
||||
for ts in timestamps:
|
||||
time_label = self.format_time(ts)
|
||||
output_path = os.path.join(self.frame_dir, f"frame_{time_label}.jpg")
|
||||
cmd = ["ffmpeg", "-ss", str(ts), "-i", self.video_path, "-frames:v", "1", "-q:v", "2", "-y", output_path,
|
||||
"-hide_banner", "-loglevel", "error"]
|
||||
subprocess.run(cmd, check=True)
|
||||
output_path = frame_results.get(ts)
|
||||
if not output_path or not os.path.exists(output_path):
|
||||
continue
|
||||
|
||||
if self.dedupe_enabled:
|
||||
frame_hash = self._calculate_file_md5(output_path)
|
||||
|
||||
Reference in New Issue
Block a user